Files
codex/codex-rs/tui/src/app_server_session.rs
T
Won Park 8033b6a449 Add /auto-review-denials retry approval flow (#19058)
## Why

Auto-review can deny an action that the user later decides they want to
retry. Today there is no TUI surface for selecting a recent denial and
sending explicit approval context back into the session, so users have
to restate intent manually and the retry can be reviewed without the
original denied action context.

This adds a narrow TUI-driven path for approving a recent denied action
while still keeping the retry inside the normal auto-review flow.

## What Changed

- Added `/auto-review-denials` to open a picker of recent denied
auto-review actions.
- Added a small in-memory TUI store for the 10 most recent denied
auto-review events.
- Selecting a denial sends the structured denied event back through the
existing core/app-server op path.
- Core now injects a developer message containing the approved action
JSON rather than the full assessment event.
- Auto-review transcript collection now preserves this specific approval
developer message so follow-up review sessions can see the user approval
context.
- Added TUI snapshot/unit coverage for the picker and approval dispatch
path.
- Added core coverage for retaining the approval developer message in
the auto-review transcript.

## Verification

- `cargo test -p codex-core
collect_guardian_transcript_entries_keeps_manual_approval_developer_message`
- `cargo test -p codex-tui auto_review_denials`
- `cargo test -p codex-tui
approving_recent_denial_emits_structured_core_op_once`

## Notes

This intentionally keeps retries going through auto-review. The approval
signal is context for the exact previously denied action, not a blanket
bypass for similar future actions.
2026-04-27 03:43:53 +00:00

1873 lines
67 KiB
Rust

use crate::bottom_pane::FeedbackAudience;
#[cfg(test)]
use crate::legacy_core::append_message_history_entry;
use crate::legacy_core::config::Config;
use crate::legacy_core::message_history_metadata;
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::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::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::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::ThreadRealtimeAppendTextParams;
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
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::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_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
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::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::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::GuardianAssessmentEvent;
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::SessionNetworkProxyRuntime;
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;
/// 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<String>,
pub(crate) auth_mode: Option<TelemetryAuthMode>,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
/// 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<ModelPreset>,
}
pub(crate) struct AppServerSession {
client: AppServerClient,
next_request_id: i64,
remote_cwd_override: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadSessionState {
pub(crate) thread_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
pub(crate) fork_parent_title: Option<String>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<codex_protocol::config_types::ServiceTier>,
pub(crate) approval_policy: AskForApproval,
pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
/// Legacy sandbox projection kept for compatibility. Use this only when
/// `permission_profile` is `None`.
pub(crate) sandbox_policy: SandboxPolicy,
/// Canonical active permissions when available. Consumers should prefer
/// this over `sandbox_policy`; `None` means the session only has a legacy
/// sandbox projection.
pub(crate) permission_profile: Option<PermissionProfile>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) instruction_source_paths: Vec<AbsolutePathBuf>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: u64,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
#[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: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
}
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<PathBuf>) -> 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<AppServerBootstrap> {
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
.wrap_err("model/list 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_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<GetAccountResponse> {
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
.wrap_err("account/read failed during TUI bootstrap")
}
pub(crate) async fn external_agent_config_detect(
&mut self,
params: ExternalAgentConfigDetectParams,
) -> Result<ExternalAgentConfigDetectResponse> {
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<ExternalAgentConfigMigrationItem>,
) -> Result<ExternalAgentConfigImportResponse> {
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<AppServerEvent> {
self.client.next_event().await
}
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
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<ThreadStartSource>,
) -> 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(),
self.remote_cwd_override.as_deref(),
session_start_source,
),
})
.await
.wrap_err("thread/start failed during TUI bootstrap")?;
started_thread_from_start_response(response, config).await
}
pub(crate) async fn resume_thread(
&mut self,
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
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
.wrap_err("thread/resume failed during TUI bootstrap")?;
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).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<AppServerStartedThread> {
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
.wrap_err("thread/fork failed during TUI bootstrap")?;
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).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<String> {
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<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")
}
/// 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<ThreadLoadedListResponse> {
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<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)
}
pub(crate) async fn thread_inject_items(
&mut self,
thread_id: ThreadId,
items: Vec<ResponseItem>,
) -> Result<ThreadInjectItemsResponse> {
let items = items
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()
.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<codex_protocol::user_input::UserInput>,
cwd: PathBuf,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
sandbox_policy: SandboxPolicy,
permission_profile: Option<PermissionProfile>,
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();
let (sandbox_policy, permission_profile) = turn_start_permission_overrides(
self.thread_params_mode(),
sandbox_policy,
permission_profile,
);
self.client
.request_typed(ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread_id.to_string(),
input: items.into_iter().map(Into::into).collect(),
responsesapi_client_metadata: None,
environments: None,
cwd: Some(cwd),
approval_policy: Some(approval_policy.into()),
approvals_reviewer: Some(approvals_reviewer.into()),
sandbox_policy,
permission_profile,
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<codex_protocol::user_input::UserInput>,
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
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(),
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<ThreadGoalGetResponse> {
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<String>,
status: Option<ThreadGoalStatus>,
token_budget: Option<Option<i64>>,
) -> Result<ThreadGoalSetResponse> {
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<ThreadGoalClearResponse> {
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<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 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 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 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,
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(),
output_modality: params.output_modality,
prompt: params.prompt,
session_id: params.session_id,
voice: params.voice,
transport: params.transport.map(|transport| match transport {
ConversationStartTransport::Websocket => {
ThreadRealtimeStartTransport::Websocket
}
ConversationStartTransport::Webrtc { sdp } => {
ThreadRealtimeStartTransport::Webrtc { sdp }
}
}),
},
})
.await
.wrap_err("thread/realtime/start failed in 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 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 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)
}
}
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(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,
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 turn_start_permission_overrides(
mode: ThreadParamsMode,
sandbox_policy: SandboxPolicy,
permission_profile: Option<PermissionProfile>,
) -> (
Option<codex_app_server_protocol::SandboxPolicy>,
Option<codex_app_server_protocol::PermissionProfile>,
) {
match (mode, permission_profile) {
(ThreadParamsMode::Embedded, Some(permission_profile)) => {
(None, Some(permission_profile.into()))
}
(ThreadParamsMode::Embedded, None) => (None, None),
(ThreadParamsMode::Remote, _) => (Some(sandbox_policy.into()), None),
}
}
fn permission_profile_override_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Option<codex_app_server_protocol::PermissionProfile> {
if matches!(thread_params_mode, ThreadParamsMode::Remote) {
return None;
}
Some(config.permissions.permission_profile().into())
}
fn thread_start_params_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
session_start_source: Option<ThreadStartSource>,
) -> ThreadStartParams {
let permission_profile = permission_profile_override_from_config(config, thread_params_mode);
let sandbox = permission_profile
.is_none()
.then(|| {
sandbox_mode_from_policy(
config
.permissions
.legacy_sandbox_policy(config.cwd.as_path()),
)
})
.flatten();
ThreadStartParams {
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_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,
permission_profile,
config: config_request_overrides_from_config(config),
ephemeral: Some(config.ephemeral),
session_start_source,
persist_extended_history: true,
..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 permission_profile = permission_profile_override_from_config(&config, thread_params_mode);
let sandbox = permission_profile
.is_none()
.then(|| {
sandbox_mode_from_policy(
config
.permissions
.legacy_sandbox_policy(config.cwd.as_path()),
)
})
.flatten();
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, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
sandbox,
permission_profile,
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,
remote_cwd_override: Option<&std::path::Path>,
) -> ThreadForkParams {
let permission_profile = permission_profile_override_from_config(&config, thread_params_mode);
let sandbox = permission_profile
.is_none()
.then(|| {
sandbox_mode_from_policy(
config
.permissions
.legacy_sandbox_policy(config.cwd.as_path()),
)
})
.flatten();
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, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
sandbox,
permission_profile,
config: config_request_overrides_from_config(&config),
base_instructions: config.base_instructions.clone(),
developer_instructions: config.developer_instructions.clone(),
ephemeral: config.ephemeral,
persist_extended_history: true,
..ThreadForkParams::default()
}
}
fn thread_cwd_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
) -> Option<String> {
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,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_start_response(&response, config)
.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,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_resume_response(&response, config)
.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,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_fork_response(&response, config)
.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,
) -> Result<ThreadSessionState, String> {
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,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.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,
) -> Result<ThreadSessionState, String> {
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,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.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,
) -> Result<ThreadSessionState, String> {
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,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.permission_profile.clone().map(Into::into),
response.cwd.clone(),
response.instruction_sources.clone(),
response.reasoning_effort,
config,
)
.await
}
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"
)]
async fn thread_session_state_from_thread_response(
thread_id: &str,
forked_from_id: Option<String>,
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,
permission_profile: Option<PermissionProfile>,
cwd: AbsolutePathBuf,
instruction_source_paths: Vec<AbsolutePathBuf>,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
config: &Config,
) -> Result<ThreadSessionState, String> {
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_log_id, history_entry_count) = message_history_metadata(config).await;
let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX);
Ok(ThreadSessionState {
thread_id,
forked_from_id,
fork_parent_title: None,
thread_name,
model,
model_provider_id,
service_tier,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
cwd,
instruction_source_paths,
reasoning_effort,
history_log_id,
history_entry_count,
network_proxy: None,
rollout_path,
})
}
pub(crate) 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,
rate_limit_reached_type: snapshot.rate_limit_reached_type.map(Into::into),
}
}
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 crate::legacy_core::config::ConfigBuilder;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
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 = build_config(&temp_dir).await;
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.sandbox, None);
assert_eq!(
params.permission_profile,
Some(config.permissions.permission_profile().into())
);
assert_eq!(params.model_provider, Some(config.model_provider_id));
}
#[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));
}
#[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_policy(
config
.permissions
.legacy_sandbox_policy(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.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, expected_sandbox);
assert_eq!(fork.sandbox, expected_sandbox);
assert_eq!(start.permission_profile, None);
assert_eq!(resume.permission_profile, None);
assert_eq!(fork.permission_profile, None);
}
#[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_policy(
config
.permissions
.legacy_sandbox_policy(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.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, expected_sandbox);
assert_eq!(fork.sandbox, expected_sandbox);
assert_eq!(start.permission_profile, None);
assert_eq!(resume.permission_profile, None);
assert_eq!(fork.permission_profile, None);
}
#[test]
fn turn_start_permission_overrides_send_profiles_only_for_embedded_runtime_overrides() {
let workspace_write = SandboxPolicy::new_workspace_write_policy();
let workspace_write_profile =
PermissionProfile::from_legacy_sandbox_policy(&workspace_write);
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Embedded,
workspace_write.clone(),
Some(workspace_write_profile.clone()),
);
assert_eq!(sandbox, None);
assert_eq!(profile, Some(workspace_write_profile.into()));
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Embedded,
workspace_write.clone(),
/*permission_profile*/ None,
);
assert_eq!(sandbox, None);
assert_eq!(profile, None);
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Remote,
workspace_write.clone(),
Some(PermissionProfile::from_legacy_sandbox_policy(
&workspace_write,
)),
);
assert_eq!(sandbox, Some(workspace_write.into()));
assert_eq!(profile, None);
let external_sandbox = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Embedded,
external_sandbox.clone(),
Some(PermissionProfile::from_legacy_sandbox_policy(
&external_sandbox,
)),
);
assert_eq!(sandbox, None);
assert_eq!(
profile,
Some(PermissionProfile::from_legacy_sandbox_policy(&external_sandbox).into())
);
}
#[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 response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
id: thread_id.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_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,
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(),
instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()],
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(),
permission_profile: Some(
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&codex_protocol::protocol::SandboxPolicy::new_read_only_policy(),
)
.into(),
),
reasoning_effort: None,
};
let started = started_thread_from_resume_response(response.clone(), &config)
.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,
response.permission_profile.clone().map(Into::into)
);
assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]);
}
#[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();
append_message_history_entry("older", &thread_id, &config)
.await
.expect("history append should succeed");
append_message_history_entry("newer", &thread_id, &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,
SandboxPolicy::new_read_only_policy(),
Some(PermissionProfile::read_only()),
test_path_buf("/tmp/project").abs(),
Vec::new(),
/*reasoning_effort*/ None,
&config,
)
.await
.expect("session should map");
assert_ne!(session.history_log_id, 0);
assert_eq!(session.history_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,
SandboxPolicy::new_read_only_policy(),
Some(PermissionProfile::read_only()),
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"
));
}
}