mirror of
https://github.com/openai/codex.git
synced 2026-05-24 04:54:52 +00:00
Builds on #23502. ## Why #23502 adds the app-server `thread/settings/update` API and matching `thread/settings/updated` notification. The TUI already lets users change thread-scoped settings such as model, reasoning effort, service tier, approvals, permissions, personality, and collaboration mode, but those updates need to flow through the app server so embedded and connected clients observe the same thread state. This is a rework (simplification) of PR https://github.com/openai/codex/pull/22510. It has the same functionality, but the underlying `thread/settings/update` api is now simpler in that it no longer returns the effective settings as a response. Now, clients receive the effective settings only through the `thread/settings/updated` notification. ## What Changed This updates the TUI to send `thread/settings/update` whenever those thread-scoped settings change and to treat the RPC response as the authoritative acknowledgement. It also routes `thread/settings/updated` notifications back into cached session state and the visible chat widget so active and inactive threads stay in sync after app-server-originated changes. The implementation is kept to the TUI layer: settings conversion and merge logic live under `codex-rs/tui/src/app/thread_settings.rs`, with dispatch/routing hooks in the existing app and chat widget paths. ## Verification I manually tested using `codex app-server --listen unix://` and then launching two copies of the TUI that use the same local app server. I then resumed the same thread on both and verified that changes like plan mode, fast mode, model, reasoning effort, etc. are reflected "live" in the second client when modified in the first and vice versa.
2453 lines
88 KiB
Rust
2453 lines
88 KiB
Rust
//! App-server session facade used by the TUI event loop.
|
|
//!
|
|
//! This module owns the typed JSON-RPC calls needed by the TUI and keeps
|
|
//! request/response plumbing out of `App` and `ChatWidget`.
|
|
|
|
use crate::bottom_pane::FeedbackAudience;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::permission_compat::legacy_compatible_permission_profile;
|
|
use crate::session_state::MessageHistoryMetadata;
|
|
use crate::session_state::ThreadSessionState;
|
|
use crate::status::StatusAccountDisplay;
|
|
use crate::status::plan_type_display_name;
|
|
use codex_app_server_client::AppServerClient;
|
|
use codex_app_server_client::AppServerEvent;
|
|
use codex_app_server_client::AppServerRequestHandle;
|
|
use codex_app_server_client::TypedRequestError;
|
|
use codex_app_server_protocol::Account;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
|
|
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
|
|
use codex_app_server_protocol::ExternalAgentConfigImportParams;
|
|
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
|
|
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
|
|
use codex_app_server_protocol::GetAccountParams;
|
|
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
|
use codex_app_server_protocol::GetAccountResponse;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::LogoutAccountResponse;
|
|
use codex_app_server_protocol::MemoryResetResponse;
|
|
use codex_app_server_protocol::Model as ApiModel;
|
|
use codex_app_server_protocol::ModelListParams;
|
|
use codex_app_server_protocol::ModelListResponse;
|
|
use codex_app_server_protocol::RateLimitSnapshot;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ReviewDelivery;
|
|
use codex_app_server_protocol::ReviewStartParams;
|
|
use codex_app_server_protocol::ReviewStartResponse;
|
|
use codex_app_server_protocol::ReviewTarget;
|
|
use codex_app_server_protocol::SkillsListParams;
|
|
use codex_app_server_protocol::SkillsListResponse;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams;
|
|
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
|
|
use codex_app_server_protocol::ThreadCompactStartParams;
|
|
use codex_app_server_protocol::ThreadCompactStartResponse;
|
|
use codex_app_server_protocol::ThreadForkParams;
|
|
use codex_app_server_protocol::ThreadForkResponse;
|
|
use codex_app_server_protocol::ThreadGoalClearParams;
|
|
use codex_app_server_protocol::ThreadGoalClearResponse;
|
|
use codex_app_server_protocol::ThreadGoalGetParams;
|
|
use codex_app_server_protocol::ThreadGoalGetResponse;
|
|
use codex_app_server_protocol::ThreadGoalSetParams;
|
|
use codex_app_server_protocol::ThreadGoalSetResponse;
|
|
use codex_app_server_protocol::ThreadGoalStatus;
|
|
use codex_app_server_protocol::ThreadInjectItemsParams;
|
|
use codex_app_server_protocol::ThreadInjectItemsResponse;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadLoadedListParams;
|
|
use codex_app_server_protocol::ThreadLoadedListResponse;
|
|
use codex_app_server_protocol::ThreadMemoryMode;
|
|
use codex_app_server_protocol::ThreadMemoryModeSetParams;
|
|
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
|
|
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
|
|
use codex_app_server_protocol::ThreadMetadataUpdateParams;
|
|
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
|
use codex_app_server_protocol::ThreadRealtimeStartParams;
|
|
use codex_app_server_protocol::ThreadRealtimeStartResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
|
use codex_app_server_protocol::ThreadRealtimeStopParams;
|
|
use codex_app_server_protocol::ThreadRealtimeStopResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadRollbackParams;
|
|
use codex_app_server_protocol::ThreadRollbackResponse;
|
|
use codex_app_server_protocol::ThreadSetNameParams;
|
|
use codex_app_server_protocol::ThreadSetNameResponse;
|
|
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
|
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
|
|
use codex_app_server_protocol::ThreadShellCommandParams;
|
|
use codex_app_server_protocol::ThreadShellCommandResponse;
|
|
use codex_app_server_protocol::ThreadSource;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadStartSource;
|
|
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
|
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnInterruptParams;
|
|
use codex_app_server_protocol::TurnInterruptResponse;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::TurnSteerParams;
|
|
use codex_app_server_protocol::TurnSteerResponse;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::approvals::GuardianAssessmentEvent;
|
|
use codex_protocol::models::ActivePermissionProfile;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ModelAvailabilityNux;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelServiceTier;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffortPreset;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use color_eyre::eyre::ContextCompat;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use uuid::Uuid;
|
|
|
|
const JSONRPC_INVALID_REQUEST: i64 = -32600;
|
|
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
|
|
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
|
|
|
|
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
|
color_eyre::eyre::eyre!("{context}: {err}")
|
|
}
|
|
|
|
fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool {
|
|
source.code == JSONRPC_METHOD_NOT_FOUND
|
|
|| (source.code == JSONRPC_INVALID_REQUEST
|
|
&& source.message.contains(THREAD_SETTINGS_UPDATE_METHOD))
|
|
}
|
|
|
|
/// 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>,
|
|
thread_params_mode: ThreadParamsMode,
|
|
thread_settings_update_supported: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct AppServerStartedThread {
|
|
pub(crate) session: ThreadSessionState,
|
|
pub(crate) turns: Vec<Turn>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum TurnPermissionsOverride {
|
|
/// Leave the app-server thread's sticky permission profile unchanged.
|
|
Preserve,
|
|
/// Select a named or built-in profile by id.
|
|
ActiveProfile(ActivePermissionProfile),
|
|
/// Apply a user-selected legacy/custom permission profile.
|
|
LegacySandbox(PermissionProfile),
|
|
}
|
|
|
|
impl AppServerSession {
|
|
pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self {
|
|
Self {
|
|
client,
|
|
next_request_id: 1,
|
|
remote_cwd_override: None,
|
|
thread_params_mode,
|
|
thread_settings_update_supported: true,
|
|
}
|
|
}
|
|
|
|
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 uses_remote_workspace(&self) -> bool {
|
|
matches!(self.thread_params_mode, ThreadParamsMode::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
|
|
.map_err(|err| {
|
|
bootstrap_request_error("model/list failed during TUI bootstrap", err)
|
|
})?;
|
|
let available_models = models
|
|
.data
|
|
.into_iter()
|
|
.map(model_preset_from_api_model)
|
|
.collect::<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
|
|
.map_err(|err| bootstrap_request_error("account/read failed during TUI bootstrap", err))
|
|
}
|
|
|
|
pub(crate) async fn external_agent_config_detect(
|
|
&mut self,
|
|
params: ExternalAgentConfigDetectParams,
|
|
) -> Result<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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
|
|
})?;
|
|
started_thread_from_start_response(response, config, self.thread_params_mode()).await
|
|
}
|
|
|
|
pub(crate) async fn resume_thread(
|
|
&mut self,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<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
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/resume failed during TUI bootstrap", err)
|
|
})?;
|
|
let fork_parent_title = self
|
|
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
|
|
.await;
|
|
let mut started =
|
|
started_thread_from_resume_response(response, &config, self.thread_params_mode())
|
|
.await?;
|
|
started.session.fork_parent_title = fork_parent_title;
|
|
Ok(started)
|
|
}
|
|
|
|
pub(crate) async fn fork_thread(
|
|
&mut self,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<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
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/fork failed during TUI bootstrap", err)
|
|
})?;
|
|
let fork_parent_title = self
|
|
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
|
|
.await;
|
|
let mut started =
|
|
started_thread_from_fork_response(response, &config, self.thread_params_mode()).await?;
|
|
started.session.fork_parent_title = fork_parent_title;
|
|
Ok(started)
|
|
}
|
|
|
|
pub(crate) fn thread_params_mode(&self) -> ThreadParamsMode {
|
|
self.thread_params_mode
|
|
}
|
|
|
|
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_metadata_update_branch(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
branch: String,
|
|
) -> Result<ThreadMetadataUpdateResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadMetadataUpdate {
|
|
request_id,
|
|
params: ThreadMetadataUpdateParams {
|
|
thread_id: thread_id.to_string(),
|
|
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
|
sha: None,
|
|
branch: Some(Some(branch)),
|
|
origin_url: None,
|
|
}),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/metadata/update failed while syncing git branch")
|
|
}
|
|
|
|
pub(crate) async fn thread_settings_update(
|
|
&mut self,
|
|
params: ThreadSettingsUpdateParams,
|
|
) -> Result<()> {
|
|
if !self.thread_settings_update_supported {
|
|
return Ok(());
|
|
}
|
|
let request_id = self.next_request_id();
|
|
match self
|
|
.client
|
|
.request_typed::<ThreadSettingsUpdateResponse>(ClientRequest::ThreadSettingsUpdate {
|
|
request_id,
|
|
params,
|
|
})
|
|
.await
|
|
{
|
|
Ok(_) => Ok(()),
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if is_thread_settings_update_unsupported(&source) =>
|
|
{
|
|
// Older remote app servers can reject this experimental method as
|
|
// method-not-found, experimental-capability-gated, or an unknown
|
|
// request variant. Treat those as a session-level capability
|
|
// downgrade so local TUI setting changes stay best-effort instead
|
|
// of showing an error every time the user changes model, effort,
|
|
// personality, or mode.
|
|
self.thread_settings_update_supported = false;
|
|
Ok(())
|
|
}
|
|
Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"),
|
|
}
|
|
}
|
|
|
|
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<UserInput>,
|
|
cwd: PathBuf,
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
|
|
permissions_override: TurnPermissionsOverride,
|
|
workspace_roots: &[AbsolutePathBuf],
|
|
model: String,
|
|
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
|
|
summary: Option<codex_protocol::config_types::ReasoningSummary>,
|
|
service_tier: Option<Option<String>>,
|
|
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, permissions) =
|
|
turn_permissions_overrides(permissions_override, cwd.as_path());
|
|
self.client
|
|
.request_typed(ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: items,
|
|
responsesapi_client_metadata: None,
|
|
environments: None,
|
|
cwd: Some(cwd),
|
|
runtime_workspace_roots: Some(
|
|
workspace_roots
|
|
.iter()
|
|
.map(AbsolutePathBuf::to_path_buf)
|
|
.collect(),
|
|
),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(approvals_reviewer.into()),
|
|
sandbox_policy,
|
|
permissions,
|
|
model: Some(model),
|
|
service_tier,
|
|
effort,
|
|
summary,
|
|
personality,
|
|
output_schema,
|
|
collaboration_mode,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn turn_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: TurnInterruptResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/interrupt failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn startup_interrupt(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
self.turn_interrupt(thread_id, String::new()).await
|
|
}
|
|
|
|
pub(crate) async fn turn_steer(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
items: Vec<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,
|
|
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,
|
|
target: ReviewTarget,
|
|
) -> 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,
|
|
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,
|
|
transport: Option<ThreadRealtimeStartTransport>,
|
|
voice: Option<serde_json::Value>,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let params = thread_realtime_start_params(thread_id, transport, voice)?;
|
|
let _: ThreadRealtimeStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStart { request_id, params })
|
|
.await
|
|
.wrap_err("thread/realtime/start failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_audio(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
frame: ThreadRealtimeAudioChunk,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeAppendAudioResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeAppendAudio {
|
|
request_id,
|
|
params: ThreadRealtimeAppendAudioParams {
|
|
thread_id: thread_id.to_string(),
|
|
audio: frame,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/appendAudio failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeStopResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStop {
|
|
request_id,
|
|
params: ThreadRealtimeStopParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/stop failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn reject_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
error: JSONRPCErrorError,
|
|
) -> std::io::Result<()> {
|
|
self.client.reject_server_request(request_id, error).await
|
|
}
|
|
|
|
pub(crate) async fn resolve_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
result: serde_json::Value,
|
|
) -> std::io::Result<()> {
|
|
self.client.resolve_server_request(request_id, result).await
|
|
}
|
|
|
|
pub(crate) async fn shutdown(self) -> std::io::Result<()> {
|
|
self.client.shutdown().await
|
|
}
|
|
|
|
pub(crate) fn request_handle(&self) -> AppServerRequestHandle {
|
|
self.client.request_handle()
|
|
}
|
|
|
|
fn next_request_id(&mut self) -> RequestId {
|
|
let request_id = self.next_request_id;
|
|
self.next_request_id += 1;
|
|
RequestId::Integer(request_id)
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn start_thread_with_request_handle(
|
|
request_handle: AppServerRequestHandle,
|
|
config: Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<PathBuf>,
|
|
) -> Result<AppServerStartedThread> {
|
|
let response: ThreadStartResponse = request_handle
|
|
.request_typed(ClientRequest::ThreadStart {
|
|
request_id: RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())),
|
|
params: thread_start_params_from_config(
|
|
&config,
|
|
thread_params_mode,
|
|
remote_cwd_override.as_deref(),
|
|
/*session_start_source*/ None,
|
|
),
|
|
})
|
|
.await
|
|
.map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?;
|
|
started_thread_from_start_response(response, &config, thread_params_mode).await
|
|
}
|
|
|
|
fn thread_realtime_start_params(
|
|
thread_id: ThreadId,
|
|
transport: Option<ThreadRealtimeStartTransport>,
|
|
voice: Option<serde_json::Value>,
|
|
) -> Result<ThreadRealtimeStartParams> {
|
|
let mut value = serde_json::Map::new();
|
|
value.insert(
|
|
"threadId".to_string(),
|
|
serde_json::Value::String(thread_id.to_string()),
|
|
);
|
|
value.insert(
|
|
"outputModality".to_string(),
|
|
serde_json::Value::String("audio".to_string()),
|
|
);
|
|
if let Some(transport) = transport {
|
|
value.insert(
|
|
"transport".to_string(),
|
|
serde_json::to_value(transport).wrap_err("serializing realtime transport")?,
|
|
);
|
|
}
|
|
if let Some(voice) = voice {
|
|
value.insert("voice".to_string(), voice);
|
|
}
|
|
|
|
serde_json::from_value(serde_json::Value::Object(value))
|
|
.wrap_err("mapping TUI realtime start params to app-server params")
|
|
}
|
|
|
|
pub(crate) fn status_account_display_from_auth_mode(
|
|
auth_mode: Option<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,
|
|
service_tiers: model
|
|
.service_tiers
|
|
.into_iter()
|
|
.map(|service_tier| ModelServiceTier {
|
|
id: service_tier.id,
|
|
name: service_tier.name,
|
|
description: service_tier.description,
|
|
})
|
|
.collect(),
|
|
is_default: model.is_default,
|
|
upgrade,
|
|
show_in_picker: !model.hidden,
|
|
availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux {
|
|
message: nux.message,
|
|
}),
|
|
// `model/list` already returns models filtered for the active client/auth context.
|
|
supported_in_api: true,
|
|
input_modalities: model.input_modalities,
|
|
}
|
|
}
|
|
|
|
fn approvals_reviewer_override_from_config(
|
|
config: &Config,
|
|
) -> Option<codex_app_server_protocol::ApprovalsReviewer> {
|
|
Some(config.approvals_reviewer.into())
|
|
}
|
|
|
|
fn config_request_overrides_from_config(
|
|
config: &Config,
|
|
) -> Option<HashMap<String, serde_json::Value>> {
|
|
let mut overrides = HashMap::new();
|
|
let mut insert = |key: &str, value: Option<String>| {
|
|
if let Some(value) = value {
|
|
overrides.insert(key.to_string(), serde_json::Value::String(value));
|
|
}
|
|
};
|
|
insert("profile", config.active_profile.clone());
|
|
insert(
|
|
"model_reasoning_effort",
|
|
config
|
|
.model_reasoning_effort
|
|
.map(|effort| effort.to_string()),
|
|
);
|
|
insert(
|
|
"model_reasoning_summary",
|
|
config
|
|
.model_reasoning_summary
|
|
.map(|summary| summary.to_string()),
|
|
);
|
|
insert(
|
|
"model_verbosity",
|
|
config
|
|
.model_verbosity
|
|
.map(|verbosity| verbosity.to_string()),
|
|
);
|
|
insert(
|
|
"personality",
|
|
config
|
|
.personality
|
|
.map(|personality| personality.to_string()),
|
|
);
|
|
insert(
|
|
"web_search",
|
|
Some(config.web_search_mode.value().to_string()),
|
|
);
|
|
Some(overrides)
|
|
}
|
|
|
|
fn service_tier_override_from_config(config: &Config) -> Option<Option<String>> {
|
|
config
|
|
.service_tier
|
|
.clone()
|
|
.map(Some)
|
|
.or_else(|| (config.notices.fast_default_opt_out == Some(true)).then_some(None))
|
|
}
|
|
|
|
fn sandbox_mode_from_permission_profile(
|
|
permission_profile: &PermissionProfile,
|
|
cwd: &std::path::Path,
|
|
) -> Option<codex_app_server_protocol::SandboxMode> {
|
|
match permission_profile {
|
|
PermissionProfile::Disabled => {
|
|
Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
|
|
}
|
|
PermissionProfile::External { .. } => None,
|
|
PermissionProfile::Managed { .. } => {
|
|
let file_system_policy = permission_profile.file_system_sandbox_policy();
|
|
if file_system_policy.has_full_disk_write_access() {
|
|
permission_profile
|
|
.network_sandbox_policy()
|
|
.is_enabled()
|
|
.then_some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
|
|
} else if file_system_policy.can_write_path_with_cwd(cwd, cwd) {
|
|
Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
|
|
} else {
|
|
Some(codex_app_server_protocol::SandboxMode::ReadOnly)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> String {
|
|
active.id
|
|
}
|
|
|
|
fn turn_permissions_overrides(
|
|
permissions_override: TurnPermissionsOverride,
|
|
cwd: &std::path::Path,
|
|
) -> (
|
|
Option<codex_app_server_protocol::SandboxPolicy>,
|
|
Option<String>,
|
|
) {
|
|
match permissions_override {
|
|
TurnPermissionsOverride::Preserve => (None, None),
|
|
TurnPermissionsOverride::ActiveProfile(active_permission_profile) => (
|
|
None,
|
|
Some(permission_profile_id_from_active_profile(
|
|
active_permission_profile,
|
|
)),
|
|
),
|
|
TurnPermissionsOverride::LegacySandbox(permission_profile) => {
|
|
let legacy_profile = legacy_compatible_permission_profile(&permission_profile, cwd);
|
|
let policy = legacy_profile
|
|
.to_legacy_sandbox_policy(cwd)
|
|
.unwrap_or_else(|err| {
|
|
unreachable!(
|
|
"legacy-compatible permissions must project to legacy policy: {err}"
|
|
)
|
|
});
|
|
(Some(policy.into()), None)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn permissions_selection_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Option<String> {
|
|
if matches!(thread_params_mode, ThreadParamsMode::Remote) {
|
|
return None;
|
|
}
|
|
|
|
config
|
|
.permissions
|
|
.active_permission_profile()
|
|
.map(permission_profile_id_from_active_profile)
|
|
}
|
|
|
|
fn thread_start_params_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
session_start_source: Option<ThreadStartSource>,
|
|
) -> ThreadStartParams {
|
|
let permissions = permissions_selection_from_config(config, thread_params_mode);
|
|
let sandbox = permissions
|
|
.is_none()
|
|
.then(|| {
|
|
sandbox_mode_from_permission_profile(
|
|
&config.permissions.effective_permission_profile(),
|
|
config.cwd.as_path(),
|
|
)
|
|
})
|
|
.flatten();
|
|
ThreadStartParams {
|
|
model: config.model.clone(),
|
|
model_provider: thread_params_mode.model_provider_from_config(config),
|
|
service_tier: service_tier_override_from_config(config),
|
|
cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: Some(
|
|
config
|
|
.workspace_roots
|
|
.iter()
|
|
.map(AbsolutePathBuf::to_path_buf)
|
|
.collect(),
|
|
),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(config),
|
|
sandbox,
|
|
permissions,
|
|
config: config_request_overrides_from_config(config),
|
|
ephemeral: Some(config.ephemeral),
|
|
session_start_source,
|
|
thread_source: Some(ThreadSource::User),
|
|
persist_extended_history: false,
|
|
..ThreadStartParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_resume_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> ThreadResumeParams {
|
|
let permissions = permissions_selection_from_config(&config, thread_params_mode);
|
|
let sandbox = permissions
|
|
.is_none()
|
|
.then(|| {
|
|
sandbox_mode_from_permission_profile(
|
|
&config.permissions.effective_permission_profile(),
|
|
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),
|
|
service_tier: service_tier_override_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: Some(
|
|
config
|
|
.workspace_roots
|
|
.iter()
|
|
.map(AbsolutePathBuf::to_path_buf)
|
|
.collect(),
|
|
),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(&config),
|
|
sandbox,
|
|
permissions,
|
|
config: config_request_overrides_from_config(&config),
|
|
persist_extended_history: false,
|
|
..ThreadResumeParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_fork_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> ThreadForkParams {
|
|
let permissions = permissions_selection_from_config(&config, thread_params_mode);
|
|
let sandbox = permissions
|
|
.is_none()
|
|
.then(|| {
|
|
sandbox_mode_from_permission_profile(
|
|
&config.permissions.effective_permission_profile(),
|
|
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),
|
|
service_tier: service_tier_override_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: Some(
|
|
config
|
|
.workspace_roots
|
|
.iter()
|
|
.map(AbsolutePathBuf::to_path_buf)
|
|
.collect(),
|
|
),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(&config),
|
|
sandbox,
|
|
permissions,
|
|
config: config_request_overrides_from_config(&config),
|
|
base_instructions: config.base_instructions.clone(),
|
|
developer_instructions: config.developer_instructions.clone(),
|
|
ephemeral: config.ephemeral,
|
|
thread_source: Some(ThreadSource::User),
|
|
persist_extended_history: false,
|
|
..ThreadForkParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_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,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_start_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_resume_response(
|
|
response: ThreadResumeResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_resume_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_fork_response(
|
|
response: ThreadForkResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_fork_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_start_response(
|
|
response: &ThreadStartResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = display_permission_profile_from_thread_response(
|
|
&response.sandbox,
|
|
response.cwd.as_path(),
|
|
config,
|
|
thread_params_mode,
|
|
);
|
|
thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.forked_from_id.clone(),
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier.clone(),
|
|
response.approval_policy,
|
|
response.approvals_reviewer.to_core(),
|
|
permission_profile,
|
|
response.active_permission_profile.clone().map(Into::into),
|
|
response.cwd.clone(),
|
|
response.runtime_workspace_roots.clone(),
|
|
response.instruction_sources.clone(),
|
|
response.reasoning_effort,
|
|
config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_resume_response(
|
|
response: &ThreadResumeResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = if matches!(thread_params_mode, ThreadParamsMode::Embedded)
|
|
&& response.active_permission_profile.is_none()
|
|
{
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
|
|
&response.sandbox.to_core(),
|
|
response.cwd.as_path(),
|
|
)
|
|
} else {
|
|
display_permission_profile_from_thread_response(
|
|
&response.sandbox,
|
|
response.cwd.as_path(),
|
|
config,
|
|
thread_params_mode,
|
|
)
|
|
};
|
|
thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.forked_from_id.clone(),
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier.clone(),
|
|
response.approval_policy,
|
|
response.approvals_reviewer.to_core(),
|
|
permission_profile,
|
|
response.active_permission_profile.clone().map(Into::into),
|
|
response.cwd.clone(),
|
|
response.runtime_workspace_roots.clone(),
|
|
response.instruction_sources.clone(),
|
|
response.reasoning_effort,
|
|
config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_fork_response(
|
|
response: &ThreadForkResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = display_permission_profile_from_thread_response(
|
|
&response.sandbox,
|
|
response.cwd.as_path(),
|
|
config,
|
|
thread_params_mode,
|
|
);
|
|
thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.forked_from_id.clone(),
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier.clone(),
|
|
response.approval_policy,
|
|
response.approvals_reviewer.to_core(),
|
|
permission_profile,
|
|
response.active_permission_profile.clone().map(Into::into),
|
|
response.cwd.clone(),
|
|
response.runtime_workspace_roots.clone(),
|
|
response.instruction_sources.clone(),
|
|
response.reasoning_effort,
|
|
config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn display_permission_profile_from_thread_response(
|
|
sandbox: &codex_app_server_protocol::SandboxPolicy,
|
|
cwd: &std::path::Path,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> PermissionProfile {
|
|
match thread_params_mode {
|
|
ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(),
|
|
ThreadParamsMode::Remote => {
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "session mapping keeps explicit fields"
|
|
)]
|
|
async fn thread_session_state_from_thread_response(
|
|
thread_id: &str,
|
|
forked_from_id: Option<String>,
|
|
thread_name: Option<String>,
|
|
rollout_path: Option<PathBuf>,
|
|
model: String,
|
|
model_provider_id: String,
|
|
service_tier: Option<String>,
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
|
|
permission_profile: PermissionProfile,
|
|
active_permission_profile: Option<ActivePermissionProfile>,
|
|
cwd: AbsolutePathBuf,
|
|
runtime_workspace_roots: Vec<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_config =
|
|
codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);
|
|
let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await;
|
|
Ok(ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id,
|
|
fork_parent_title: None,
|
|
thread_name,
|
|
model,
|
|
model_provider_id,
|
|
service_tier,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
permission_profile,
|
|
active_permission_profile,
|
|
cwd,
|
|
runtime_workspace_roots,
|
|
instruction_source_paths,
|
|
reasoning_effort,
|
|
collaboration_mode: None,
|
|
personality: config.personality,
|
|
message_history: Some(MessageHistoryMetadata {
|
|
log_id,
|
|
entry_count,
|
|
}),
|
|
network_proxy: None,
|
|
rollout_path,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn app_server_rate_limit_snapshots(
|
|
response: GetAccountRateLimitsResponse,
|
|
) -> Vec<RateLimitSnapshot> {
|
|
let primary_limit_id = response.rate_limits.limit_id.clone();
|
|
let mut snapshots = vec![response.rate_limits];
|
|
if let Some(by_limit_id) = response.rate_limits_by_limit_id {
|
|
snapshots.extend(by_limit_id.into_iter().filter_map(|(limit_id, snapshot)| {
|
|
if primary_limit_id.as_deref().is_some_and(|primary_limit_id| {
|
|
primary_limit_id == limit_id
|
|
|| Some(primary_limit_id) == snapshot.limit_id.as_deref()
|
|
}) {
|
|
None
|
|
} else {
|
|
Some(snapshot)
|
|
}
|
|
}));
|
|
}
|
|
snapshots
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::legacy_core::config::ConfigBuilder;
|
|
use crate::legacy_core::config::ConfigOverrides;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::config_types::Verbosity;
|
|
use codex_protocol::config_types::WebSearchMode;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
|
use codex_protocol::models::ManagedFileSystemPermissions;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
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")
|
|
}
|
|
|
|
fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot {
|
|
RateLimitSnapshot {
|
|
limit_id: Some(limit_id.to_string()),
|
|
limit_name: None,
|
|
primary: Some(codex_app_server_protocol::RateLimitWindow {
|
|
used_percent: 0,
|
|
window_duration_mins: Some(10_080),
|
|
resets_at: None,
|
|
}),
|
|
secondary: None,
|
|
credits: None,
|
|
plan_type: None,
|
|
rate_limit_reached_type: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() {
|
|
let response = GetAccountRateLimitsResponse {
|
|
rate_limits: rate_limit_snapshot("codex"),
|
|
rate_limits_by_limit_id: Some(HashMap::from([
|
|
("codex".to_string(), rate_limit_snapshot("codex")),
|
|
("other".to_string(), rate_limit_snapshot("other")),
|
|
])),
|
|
};
|
|
|
|
let snapshots = app_server_rate_limit_snapshots(response);
|
|
|
|
assert_eq!(
|
|
snapshots
|
|
.iter()
|
|
.map(|snapshot| snapshot.limit_id.as_deref())
|
|
.collect::<Vec<_>>(),
|
|
vec![Some("codex"), Some("other")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_settings_update_compat_detects_unsupported_errors() {
|
|
let cases = [
|
|
(JSONRPC_METHOD_NOT_FOUND, "method not found", true),
|
|
(
|
|
JSONRPC_INVALID_REQUEST,
|
|
"thread/settings/update requires experimentalApi capability",
|
|
true,
|
|
),
|
|
(
|
|
JSONRPC_INVALID_REQUEST,
|
|
"Invalid request: unknown variant `thread/settings/update`",
|
|
true,
|
|
),
|
|
(JSONRPC_INVALID_REQUEST, "invalid thread id", false),
|
|
];
|
|
|
|
for (code, message, expected) in cases {
|
|
let source = JSONRPCErrorError {
|
|
code,
|
|
data: None,
|
|
message: message.to_string(),
|
|
};
|
|
assert_eq!(
|
|
is_thread_settings_update_unsupported(&source),
|
|
expected,
|
|
"{message}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_params_include_cwd_for_embedded_sessions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
|
|
..ConfigOverrides::default()
|
|
})
|
|
.build()
|
|
.await
|
|
.expect("config should build");
|
|
|
|
let params = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
|
|
assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string()));
|
|
assert_eq!(
|
|
params.runtime_workspace_roots,
|
|
Some(
|
|
config
|
|
.workspace_roots
|
|
.iter()
|
|
.map(AbsolutePathBuf::to_path_buf)
|
|
.collect()
|
|
)
|
|
);
|
|
assert_eq!(params.sandbox, None);
|
|
assert_eq!(
|
|
params.permissions,
|
|
config
|
|
.permissions
|
|
.active_permission_profile()
|
|
.map(permission_profile_id_from_active_profile)
|
|
);
|
|
assert_eq!(params.model_provider, Some(config.model_provider_id));
|
|
assert_eq!(params.thread_source, Some(ThreadSource::User));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_params_can_mark_clear_source() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
|
|
let params = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
Some(ThreadStartSource::Clear),
|
|
);
|
|
|
|
assert_eq!(params.session_start_source, Some(ThreadStartSource::Clear));
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_turn_permissions_use_active_profile_selection() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let active_permission_profile =
|
|
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
|
|
let expected_permissions =
|
|
permission_profile_id_from_active_profile(active_permission_profile.clone());
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
|
|
cwd.as_path(),
|
|
);
|
|
|
|
assert_eq!(sandbox_policy, None);
|
|
assert_eq!(permissions, Some(expected_permissions));
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_turn_permissions_select_profile_id_only() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let active_permission_profile =
|
|
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
|
|
cwd.as_path(),
|
|
);
|
|
|
|
assert_eq!(sandbox_policy, None);
|
|
assert_eq!(
|
|
permissions,
|
|
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_permissions_preserve_thread_permissions_without_override() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
|
|
let (sandbox_policy, permissions) =
|
|
turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path());
|
|
|
|
assert_eq!(sandbox_policy, None);
|
|
assert_eq!(permissions, None);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_turn_permissions_project_to_sandbox_when_explicitly_overridden() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
TurnPermissionsOverride::LegacySandbox(PermissionProfile::read_only()),
|
|
cwd.as_path(),
|
|
);
|
|
|
|
assert_eq!(
|
|
sandbox_policy,
|
|
Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
|
network_access: false
|
|
})
|
|
);
|
|
assert_eq!(permissions, None);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_turn_permissions_preserve_active_profile_selection() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let active_permission_profile = ActivePermissionProfile::new("strict");
|
|
let expected_permissions =
|
|
permission_profile_id_from_active_profile(active_permission_profile.clone());
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
TurnPermissionsOverride::ActiveProfile(active_permission_profile),
|
|
cwd.as_path(),
|
|
);
|
|
|
|
assert_eq!(sandbox_policy, None);
|
|
assert_eq!(permissions, Some(expected_permissions));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
let expected_sandbox = sandbox_mode_from_permission_profile(
|
|
&config.permissions.effective_permission_profile(),
|
|
config.cwd.as_path(),
|
|
);
|
|
let expected_runtime_workspace_roots = Some(
|
|
config
|
|
.workspace_roots
|
|
.iter()
|
|
.map(AbsolutePathBuf::to_path_buf)
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
|
|
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.runtime_workspace_roots,
|
|
expected_runtime_workspace_roots
|
|
);
|
|
assert_eq!(
|
|
resume.runtime_workspace_roots,
|
|
expected_runtime_workspace_roots
|
|
);
|
|
assert_eq!(
|
|
fork.runtime_workspace_roots,
|
|
expected_runtime_workspace_roots
|
|
);
|
|
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.permissions, None);
|
|
assert_eq!(resume.permissions, None);
|
|
assert_eq!(fork.permissions, None);
|
|
assert_eq!(start.thread_source, Some(ThreadSource::User));
|
|
assert_eq!(fork.thread_source, Some(ThreadSource::User));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let extra_root = test_path_buf("/workspace/cache").abs();
|
|
let permission_profile: PermissionProfile = PermissionProfile::Managed {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
file_system: ManagedFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: extra_root },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
|
|
Some(codex_app_server_protocol::SandboxMode::ReadOnly)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_mode_projects_cwd_write_for_remote_sessions() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let permission_profile: PermissionProfile = PermissionProfile::Managed {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
file_system: ManagedFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
|
|
Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_lifecycle_params_forward_explicit_remote_cwd_override_for_remote_sessions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
let remote_cwd = PathBuf::from("repo/on/server");
|
|
let expected_sandbox = sandbox_mode_from_permission_profile(
|
|
&config.permissions.effective_permission_profile(),
|
|
config.cwd.as_path(),
|
|
);
|
|
|
|
let start = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
Some(remote_cwd.as_path()),
|
|
/*session_start_source*/ None,
|
|
);
|
|
let resume = thread_resume_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
ThreadParamsMode::Remote,
|
|
Some(remote_cwd.as_path()),
|
|
);
|
|
let fork = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Remote,
|
|
Some(remote_cwd.as_path()),
|
|
);
|
|
|
|
assert_eq!(start.cwd.as_deref(), Some("repo/on/server"));
|
|
assert_eq!(resume.cwd.as_deref(), Some("repo/on/server"));
|
|
assert_eq!(fork.cwd.as_deref(), Some("repo/on/server"));
|
|
assert_eq!(start.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.permissions, None);
|
|
assert_eq!(resume.permissions, None);
|
|
assert_eq!(fork.permissions, None);
|
|
assert_eq!(start.thread_source, Some(ThreadSource::User));
|
|
assert_eq!(fork.thread_source, Some(ThreadSource::User));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_lifecycle_params_forward_model_reasoning_and_service_tier() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.model_reasoning_effort = Some(ReasoningEffort::High);
|
|
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
|
|
config.model_verbosity = Some(Verbosity::Low);
|
|
config.personality = Some(Personality::Pragmatic);
|
|
config
|
|
.web_search_mode
|
|
.set(WebSearchMode::Disabled)
|
|
.expect("test web search mode should be allowed");
|
|
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
|
let thread_id = ThreadId::new();
|
|
|
|
let start = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
let resume = thread_resume_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
let fork = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
|
|
let expected_service_tier = Some(Some(ServiceTier::Fast.request_value().to_string()));
|
|
assert_eq!(start.service_tier, expected_service_tier);
|
|
assert_eq!(resume.service_tier, expected_service_tier);
|
|
assert_eq!(fork.service_tier, expected_service_tier);
|
|
let string = |value: &str| serde_json::Value::String(value.to_string());
|
|
let expected_config = HashMap::from([
|
|
("model_reasoning_effort".to_string(), string("high")),
|
|
("model_reasoning_summary".to_string(), string("detailed")),
|
|
("model_verbosity".to_string(), string("low")),
|
|
("personality".to_string(), string("pragmatic")),
|
|
("web_search".to_string(), string("disabled")),
|
|
]);
|
|
assert_eq!(start.config, Some(expected_config.clone()));
|
|
assert_eq!(resume.config, Some(expected_config.clone()));
|
|
assert_eq!(fork.config, Some(expected_config));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_request_overrides_preserve_implicit_personality_default() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.personality = None;
|
|
|
|
let implicit_overrides =
|
|
config_request_overrides_from_config(&config).expect("config overrides");
|
|
|
|
assert!(!implicit_overrides.contains_key("personality"));
|
|
|
|
config.personality = Some(Personality::None);
|
|
let explicit_overrides =
|
|
config_request_overrides_from_config(&config).expect("config overrides");
|
|
|
|
assert_eq!(
|
|
explicit_overrides.get("personality"),
|
|
Some(&serde_json::Value::String("none".to_string()))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_params_forward_instruction_overrides() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.base_instructions = Some("Base override.".to_string());
|
|
config.developer_instructions = Some("Developer override.".to_string());
|
|
let thread_id = ThreadId::new();
|
|
|
|
let params = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
|
|
assert_eq!(params.base_instructions.as_deref(), Some("Base override."));
|
|
assert_eq!(
|
|
params.developer_instructions.as_deref(),
|
|
Some("Developer override.")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_response_restores_turns_from_thread_items() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
let forked_from_id = ThreadId::new();
|
|
let read_only_profile = PermissionProfile::read_only();
|
|
let response = ThreadResumeResponse {
|
|
thread: codex_app_server_protocol::Thread {
|
|
id: thread_id.to_string(),
|
|
session_id: ThreadId::new().to_string(),
|
|
forked_from_id: Some(forked_from_id.to_string()),
|
|
preview: "hello".to_string(),
|
|
ephemeral: false,
|
|
model_provider: "openai".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
status: ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: codex_app_server_protocol::SessionSource::Cli,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: vec![Turn {
|
|
id: "turn-1".to_string(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![
|
|
codex_app_server_protocol::ThreadItem::UserMessage {
|
|
id: "user-1".to_string(),
|
|
content: vec![codex_app_server_protocol::UserInput::Text {
|
|
text: "hello from history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
},
|
|
codex_app_server_protocol::ThreadItem::AgentMessage {
|
|
id: "assistant-1".to_string(),
|
|
text: "assistant reply".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}],
|
|
},
|
|
model: "gpt-5.4".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
runtime_workspace_roots: vec![
|
|
test_path_buf("/tmp/project").abs(),
|
|
test_path_buf("/tmp/project/extra").abs(),
|
|
],
|
|
instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()],
|
|
approval_policy: codex_app_server_protocol::AskForApproval::Never,
|
|
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
|
|
sandbox: read_only_profile
|
|
.to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path())
|
|
.expect("read-only profile must be legacy-compatible")
|
|
.into(),
|
|
active_permission_profile: None,
|
|
reasoning_effort: None,
|
|
};
|
|
|
|
let started = started_thread_from_resume_response(
|
|
response.clone(),
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
)
|
|
.await
|
|
.expect("resume response should map");
|
|
assert_eq!(started.session.forked_from_id, Some(forked_from_id));
|
|
assert_eq!(
|
|
started.session.runtime_workspace_roots,
|
|
response.runtime_workspace_roots
|
|
);
|
|
assert_eq!(
|
|
started.session.instruction_source_paths,
|
|
response.instruction_sources
|
|
);
|
|
assert_eq!(started.session.permission_profile, read_only_profile);
|
|
assert_eq!(started.turns.len(), 1);
|
|
assert_eq!(started.turns[0], response.thread.turns[0]);
|
|
|
|
let embedded_config = ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().join("embedded-codex-home"))
|
|
.harness_overrides(ConfigOverrides {
|
|
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
|
|
..ConfigOverrides::default()
|
|
})
|
|
.build()
|
|
.await
|
|
.expect("config should build");
|
|
let started = started_thread_from_resume_response(
|
|
response.clone(),
|
|
&embedded_config,
|
|
ThreadParamsMode::Embedded,
|
|
)
|
|
.await
|
|
.expect("embedded resume response should map");
|
|
assert_eq!(started.session.permission_profile, read_only_profile);
|
|
|
|
let mut empty_roots_response = response;
|
|
empty_roots_response.runtime_workspace_roots = Vec::new();
|
|
let started = started_thread_from_resume_response(
|
|
empty_roots_response,
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
)
|
|
.await
|
|
.expect("resume response should map");
|
|
assert_eq!(started.session.runtime_workspace_roots, Vec::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_thread_response_uses_legacy_sandbox_fallback() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let cwd = test_path_buf("/tmp/project").abs();
|
|
let sandbox = PermissionProfile::read_only()
|
|
.to_legacy_sandbox_policy(cwd.as_path())
|
|
.expect("read-only profile must be legacy-compatible")
|
|
.into();
|
|
|
|
assert_eq!(
|
|
display_permission_profile_from_thread_response(
|
|
&sandbox,
|
|
cwd.as_path(),
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
),
|
|
PermissionProfile::read_only()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn embedded_thread_response_uses_local_config_profile() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
|
|
..ConfigOverrides::default()
|
|
})
|
|
.build()
|
|
.await
|
|
.expect("config should build");
|
|
let cwd = test_path_buf("/tmp/project").abs();
|
|
|
|
assert_eq!(
|
|
display_permission_profile_from_thread_response(
|
|
&codex_app_server_protocol::SandboxPolicy::DangerFullAccess,
|
|
cwd.as_path(),
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
),
|
|
PermissionProfile::read_only()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_populates_history_metadata() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
|
|
let history_config =
|
|
codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);
|
|
|
|
codex_message_history::append_entry("older", &thread_id, &history_config)
|
|
.await
|
|
.expect("history append should succeed");
|
|
codex_message_history::append_entry("newer", &thread_id, &history_config)
|
|
.await
|
|
.expect("history append should succeed");
|
|
|
|
let session = thread_session_state_from_thread_response(
|
|
&thread_id.to_string(),
|
|
/*forked_from_id*/ None,
|
|
Some("restore".to_string()),
|
|
/*rollout_path*/ None,
|
|
"gpt-5.4".to_string(),
|
|
"openai".to_string(),
|
|
/*service_tier*/ None,
|
|
AskForApproval::Never,
|
|
codex_protocol::config_types::ApprovalsReviewer::User,
|
|
PermissionProfile::read_only(),
|
|
/*active_permission_profile*/ None,
|
|
test_path_buf("/tmp/project").abs(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
/*reasoning_effort*/ None,
|
|
&config,
|
|
)
|
|
.await
|
|
.expect("session should map");
|
|
|
|
let metadata = session
|
|
.message_history
|
|
.expect("session should include message-history metadata");
|
|
assert_ne!(metadata.log_id, 0);
|
|
assert_eq!(metadata.entry_count, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_preserves_fork_source_thread_id() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
let forked_from_id = ThreadId::new();
|
|
|
|
let session = thread_session_state_from_thread_response(
|
|
&thread_id.to_string(),
|
|
Some(forked_from_id.to_string()),
|
|
Some("restore".to_string()),
|
|
/*rollout_path*/ None,
|
|
"gpt-5.4".to_string(),
|
|
"openai".to_string(),
|
|
/*service_tier*/ None,
|
|
AskForApproval::Never,
|
|
codex_protocol::config_types::ApprovalsReviewer::User,
|
|
PermissionProfile::read_only(),
|
|
/*active_permission_profile*/ None,
|
|
test_path_buf("/tmp/project").abs(),
|
|
Vec::new(),
|
|
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"
|
|
));
|
|
}
|
|
}
|