mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
## What changed - Return the selected workspace's display name as `workspaceName` in ChatGPT account responses, caching it alongside workspace routing and updating generated schemas and bindings. - Display a `ChatGPT workspace` row with the name and account ID, falling back to the ID when the name is unavailable or invalid. Refresh workspace details with account identity updates and hide the row for providers that do not use ChatGPT authentication. ## Testing Update account-response tests to cover workspace names and null values, add workspace details to the status snapshot, and verify that switching to a custom API-key provider hides the workspace row. GitOrigin-RevId: 4b30ab8a39e1fa7ad1ba7367c5295544be8f6001
4867 lines
183 KiB
Rust
4867 lines
183 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`.
|
|
|
|
mod daybreak_api_key_compat;
|
|
mod events;
|
|
mod external_agent_config;
|
|
pub(crate) mod fs;
|
|
mod history;
|
|
mod models;
|
|
pub(crate) mod provider_selection;
|
|
#[cfg(test)]
|
|
#[path = "app_server_session/provider_selection_tests.rs"]
|
|
mod provider_selection_tests;
|
|
mod realtime;
|
|
mod rollout_history;
|
|
mod startup_launch;
|
|
mod thread_list;
|
|
mod web_search;
|
|
|
|
#[cfg(test)]
|
|
#[path = "app_server_session/collaboration_catalog_tests.rs"]
|
|
mod collaboration_catalog_tests;
|
|
|
|
pub(crate) use events::AppServerSessionEvent;
|
|
pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT;
|
|
pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT;
|
|
pub(crate) use history::HistoryHydrationScope;
|
|
pub(crate) use history::INITIAL_HISTORY_TURN_LIMIT;
|
|
pub(crate) use history::thread_items_page_params;
|
|
pub(crate) use startup_launch::StartupLaunchChoices;
|
|
|
|
use crate::app_event::PermissionProfileSelection;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::bottom_pane::FeedbackAudience;
|
|
use crate::dynamic_tools_mcp::DynamicToolMcpServer;
|
|
use crate::dynamic_tools_mcp::ThreadToolTransport;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::local_settings::LocalSettings;
|
|
use crate::service_tier_resolution;
|
|
use crate::session_state::MessageHistoryMetadata;
|
|
use crate::session_state::ThreadSessionState;
|
|
use crate::status::StatusAccountDisplay;
|
|
use crate::status::plan_type_display_name;
|
|
use crate::terminal_visualization_instructions::with_terminal_visualization_instructions;
|
|
use codex_app_server_client::AppServerClient;
|
|
use codex_app_server_client::AppServerPath;
|
|
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::ConfigRequirementsReadResponse;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
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::NewThreadModelDefaults;
|
|
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::SessionSource;
|
|
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::ThreadArchiveParams;
|
|
use codex_app_server_protocol::ThreadArchiveResponse;
|
|
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::ThreadDeleteParams;
|
|
use codex_app_server_protocol::ThreadDeleteResponse;
|
|
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::ThreadHistoryMode;
|
|
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::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
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::ThreadStatusChangedNotification;
|
|
use codex_app_server_protocol::ThreadUnarchiveParams;
|
|
use codex_app_server_protocol::ThreadUnarchiveResponse;
|
|
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_config::ConfigLayerSource;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::approvals::GuardianAssessmentEvent;
|
|
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
|
use codex_protocol::models::ActivePermissionProfile;
|
|
use codex_protocol::models::BaseInstructionsProvenance;
|
|
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_protocol::protocol::SubAgentSource;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use color_eyre::eyre::ContextCompat;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
use uuid::Uuid;
|
|
|
|
const JSONRPC_INVALID_REQUEST: i64 = -32600;
|
|
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
|
|
const JSONRPC_INVALID_PARAMS: i64 = -32602;
|
|
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again.";
|
|
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ForkGoalContinuation {
|
|
StartIfIdle,
|
|
DeferUntilNextTurn,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ForkPermissionMode {
|
|
InheritSaved,
|
|
OverrideFromCurrentConfig,
|
|
}
|
|
|
|
enum EmptyCatalogFallback {
|
|
Error,
|
|
ManagedNewThread,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ForkPresentation {
|
|
Regular,
|
|
SideConversation,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ForkConfigSource {
|
|
Local,
|
|
Session,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ThreadHistorySupport {
|
|
Paginated,
|
|
LegacyOnly,
|
|
}
|
|
|
|
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
|
let message = format!("{context}: {err}");
|
|
color_eyre::Report::new(err).wrap_err(message)
|
|
}
|
|
|
|
pub(crate) fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool {
|
|
if source.code == JSONRPC_METHOD_NOT_FOUND {
|
|
return true;
|
|
}
|
|
|
|
if !matches!(
|
|
source.code,
|
|
JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
let message = source.message.to_ascii_lowercase();
|
|
[
|
|
"historymode",
|
|
"history mode",
|
|
"excludeturns",
|
|
"exclude turns",
|
|
"thread/turns/list",
|
|
"thread/items/list",
|
|
]
|
|
.into_iter()
|
|
.any(|field| message.contains(field))
|
|
|| (message.contains("paginated")
|
|
&& ["unknown variant", "unsupported variant", "invalid enum"]
|
|
.into_iter()
|
|
.any(|error| message.contains(error)))
|
|
}
|
|
|
|
pub(crate) async fn request_thread_start_with_history_fallback(
|
|
request_handle: &AppServerRequestHandle,
|
|
mut request_id: RequestId,
|
|
mut params: ThreadStartParams,
|
|
) -> std::result::Result<(ThreadStartResponse, ThreadHistorySupport, bool), TypedRequestError> {
|
|
let mut history_support = ThreadHistorySupport::Paginated;
|
|
loop {
|
|
match request_handle
|
|
.request_typed(ClientRequest::ThreadStart {
|
|
request_id,
|
|
params: params.clone(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => {
|
|
let task_tools_available = params.dynamic_tools.is_some()
|
|
|| params
|
|
.config
|
|
.as_ref()
|
|
.is_some_and(|config| config.contains_key("mcp_servers.codex_tui"));
|
|
return Ok((response, history_support, task_tools_available));
|
|
}
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if params.history_mode.is_some() && is_history_pagination_unsupported(&source) =>
|
|
{
|
|
params.history_mode = None;
|
|
history_support = ThreadHistorySupport::LegacyOnly;
|
|
request_id = RequestId::String(format!("legacy-thread-start-{}", Uuid::new_v4()));
|
|
}
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if params.dynamic_tools.is_some()
|
|
&& matches!(
|
|
source.code,
|
|
JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS
|
|
)
|
|
&& {
|
|
let message = source.message.to_ascii_lowercase();
|
|
["dynamictools", "dynamic tool", "namespace", "inputschema"]
|
|
.into_iter()
|
|
.any(|field| message.contains(field))
|
|
} =>
|
|
{
|
|
tracing::warn!(
|
|
error = %source.message,
|
|
"app server does not support TUI dynamic tools; starting without them"
|
|
);
|
|
params.dynamic_tools = None;
|
|
request_id = RequestId::String(format!("legacy-thread-start-{}", Uuid::new_v4()));
|
|
}
|
|
Err(err) => return Err(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) duration: Duration,
|
|
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) collaboration_modes: Vec<codex_protocol::config_types::CollaborationModeMask>,
|
|
pub(crate) allowed_approval_policies: Option<Vec<AskForApproval>>,
|
|
}
|
|
|
|
pub(crate) struct AppServerSession {
|
|
backend_id: crate::backend_id::BackendId,
|
|
client: AppServerClient,
|
|
next_request_id: i64,
|
|
history_pagination: HashMap<ThreadId, history::ThreadHistoryPagination>,
|
|
task_tool_threads: HashSet<ThreadId>,
|
|
task_tool_capabilities_dir: Option<AbsolutePathBuf>,
|
|
task_search_generation: Arc<AtomicU64>,
|
|
remote_cwd_override: Option<PathBuf>,
|
|
thread_params_mode: ThreadParamsMode,
|
|
/// Explicit CLI provider selection, distinct from resolved configuration defaults.
|
|
pub(crate) model_provider_override: Option<String>,
|
|
history_support: ThreadHistorySupport,
|
|
pub(crate) thread_settings_update_supported: bool,
|
|
default_model: Option<String>,
|
|
available_models: Vec<ModelPreset>,
|
|
daybreak_api_key_compat: Arc<tokio::sync::Semaphore>,
|
|
managed_new_thread_defaults: Option<NewThreadModelDefaults>,
|
|
external_agent_config_import_id: Mutex<Option<String>>,
|
|
dynamic_tool_mcp: Option<Arc<DynamicToolMcpServer>>,
|
|
pub(crate) worktree_source_config_builder:
|
|
Option<Box<crate::legacy_core::config::ConfigBuilder>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum ThreadParamsMode {
|
|
Embedded,
|
|
Remote,
|
|
/// Native hosted sessions own their execution settings and workspace.
|
|
Cloud,
|
|
}
|
|
|
|
/// Identifies which side supplies execution settings for a conversation.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum ThreadSettingsOwner {
|
|
/// The client may send its configured settings as request overrides.
|
|
Client,
|
|
/// The server retains its settings unless changed through a dedicated API.
|
|
Server,
|
|
}
|
|
|
|
/// Determines where model settings come from when resuming a thread.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum ResumeModelSettings {
|
|
/// Sends the current config's model, provider, and reasoning effort as explicit overrides.
|
|
OverrideFromCurrentConfig,
|
|
/// Omits those overrides so app-server restores the settings saved with the thread.
|
|
RestoreFromThread,
|
|
/// Rejoins a loaded thread without changing any of its existing settings.
|
|
PreserveExistingThread,
|
|
}
|
|
|
|
impl ThreadParamsMode {
|
|
pub(crate) fn settings_owner(self) -> ThreadSettingsOwner {
|
|
match self {
|
|
Self::Embedded | Self::Remote => ThreadSettingsOwner::Client,
|
|
Self::Cloud => ThreadSettingsOwner::Server,
|
|
}
|
|
}
|
|
|
|
fn workspace_roots_from_config(self, config: &Config) -> Option<Vec<AbsolutePathBuf>> {
|
|
match self {
|
|
Self::Embedded => Some(config.workspace_roots.clone()),
|
|
// Client config paths belong to the client host. Let the server resolve its
|
|
// defaults or restore the saved roots, then adopt the roots in its response.
|
|
Self::Remote | Self::Cloud => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct AppServerStartedThread {
|
|
pub(crate) session: ThreadSessionState,
|
|
pub(crate) turns: Vec<Turn>,
|
|
pub(crate) blocks_direct_input: bool,
|
|
pub(crate) task_tools_available: bool,
|
|
}
|
|
|
|
pub(crate) fn is_active_writer_error(err: &color_eyre::eyre::Report) -> bool {
|
|
err.chain()
|
|
.any(|cause| cause.to_string().contains("already has an active writer"))
|
|
}
|
|
|
|
/// Recognizes a draining server's refusal, using the legacy message only when data is absent.
|
|
pub(crate) fn is_server_shutting_down_error(error: &JSONRPCErrorError) -> bool {
|
|
error.code == JSONRPC_INVALID_REQUEST
|
|
&& match &error.data {
|
|
Some(data) => {
|
|
data.get("reason").and_then(serde_json::Value::as_str) == Some("serverShuttingDown")
|
|
}
|
|
None => error.message == "Server is draining; retry after reconnecting",
|
|
}
|
|
}
|
|
|
|
pub(crate) fn source_agent_path(source: &SessionSource) -> Option<String> {
|
|
match source {
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
|
|
agent_path.clone().map(String::from)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Uses the server capability when available and preserves compatibility with older servers.
|
|
pub(crate) fn thread_blocks_direct_input(thread: &Thread) -> bool {
|
|
thread
|
|
.can_accept_direct_input
|
|
.map(|can_accept| !can_accept)
|
|
.unwrap_or_else(|| source_agent_path(&thread.source).is_some())
|
|
}
|
|
|
|
#[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),
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error(
|
|
"the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile"
|
|
)]
|
|
pub(crate) struct UnsupportedLegacyPermissionProfile;
|
|
|
|
impl AppServerSession {
|
|
/// Platform of the app-server process, not necessarily its executor.
|
|
pub(crate) fn app_server_platform_family(&self) -> Option<&str> {
|
|
self.client.platform_family()
|
|
}
|
|
|
|
pub(crate) fn app_server_platform_os(&self) -> Option<&str> {
|
|
self.client.platform_os()
|
|
}
|
|
|
|
pub(crate) fn new(
|
|
backend_id: crate::backend_id::BackendId,
|
|
client: AppServerClient,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Self {
|
|
Self {
|
|
backend_id,
|
|
client,
|
|
next_request_id: 1,
|
|
history_pagination: HashMap::new(),
|
|
task_tool_threads: HashSet::new(),
|
|
task_tool_capabilities_dir: None,
|
|
task_search_generation: Arc::new(AtomicU64::new(0)),
|
|
remote_cwd_override: None,
|
|
thread_params_mode,
|
|
model_provider_override: None,
|
|
history_support: ThreadHistorySupport::Paginated,
|
|
thread_settings_update_supported: true,
|
|
default_model: None,
|
|
available_models: Vec::new(),
|
|
daybreak_api_key_compat: Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
|
|
managed_new_thread_defaults: None,
|
|
external_agent_config_import_id: Mutex::default(),
|
|
dynamic_tool_mcp: None,
|
|
worktree_source_config_builder: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn backend_id(&self) -> crate::backend_id::BackendId {
|
|
self.backend_id
|
|
}
|
|
|
|
pub(crate) async fn start_dynamic_tool_mcp(
|
|
&mut self,
|
|
config: Config,
|
|
app_event_tx: AppEventSender,
|
|
status_updates: tokio::sync::broadcast::Sender<ThreadStatusChangedNotification>,
|
|
) -> std::io::Result<()> {
|
|
if self.thread_params_mode == ThreadParamsMode::Cloud {
|
|
return Ok(());
|
|
}
|
|
let worktrees = if !self.uses_remote_workspace() && !config.active_project.is_untrusted() {
|
|
// Listing a fresh, nonexistent thread is read-only and returns an empty page
|
|
// on supported stores. Probe before advertising tools or allocating a checkout.
|
|
let support = self
|
|
.request_handle()
|
|
.request_typed::<codex_app_server_protocol::ThreadAttachmentListResponse>(
|
|
ClientRequest::ThreadAttachmentList {
|
|
request_id: RequestId::String(uuid::Uuid::new_v4().to_string()),
|
|
params: codex_app_server_protocol::ThreadAttachmentListParams {
|
|
thread_id: uuid::Uuid::new_v4().to_string(),
|
|
cursor: None,
|
|
limit: Some(1),
|
|
},
|
|
},
|
|
)
|
|
.await;
|
|
match support {
|
|
Err(error) => {
|
|
tracing::warn!(%error, "managed worktree attachment storage unavailable");
|
|
None
|
|
}
|
|
Ok(_) => {
|
|
match crate::managed_worktree_tools::ManagedWorktreeTools::new(&config).await {
|
|
Ok(service) => Some(match self.worktree_source_config_builder.as_deref() {
|
|
Some(builder) => service.with_source_config_builder(builder.clone()),
|
|
None => service,
|
|
}),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "managed worktree tools unavailable");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let services = crate::dynamic_tools_mcp::ToolServices {
|
|
task_tools: !self.uses_embedded_app_server(),
|
|
worktrees,
|
|
};
|
|
if !services.task_tools && services.worktrees.is_none() {
|
|
return Ok(());
|
|
}
|
|
if config.mcp_servers.get().contains_key(services.namespace()) {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::AlreadyExists,
|
|
"a user-configured MCP server already owns the TUI tools namespace",
|
|
));
|
|
}
|
|
let managed_requirement = config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.mcp_servers
|
|
.as_ref()
|
|
.map(|requirements| {
|
|
requirements.value.get(services.namespace()).ok_or_else(|| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::PermissionDenied,
|
|
"managed MCP requirements do not permit the TUI task-tools server",
|
|
)
|
|
})
|
|
})
|
|
.transpose()?;
|
|
let mut thread_start_params = thread_start_params_from_config(
|
|
&config,
|
|
self.thread_params_mode(),
|
|
self.remote_cwd_override(),
|
|
/*session_start_source*/ None,
|
|
);
|
|
let daybreak_launch_override = config
|
|
.config_layer_stack
|
|
.layers_high_to_low()
|
|
.find(|layer| layer.config.get("daybreak").is_some())
|
|
.is_some_and(|layer| {
|
|
matches!(
|
|
layer.name,
|
|
codex_config::ConfigLayerSource::SessionFlags
|
|
| codex_config::ConfigLayerSource::User {
|
|
profile: Some(_),
|
|
..
|
|
}
|
|
)
|
|
});
|
|
thread_start_params.daybreak_enabled =
|
|
daybreak_launch_override.then_some(config.daybreak_enabled);
|
|
self.dynamic_tool_mcp = Some(Arc::new(
|
|
DynamicToolMcpServer::start(
|
|
self.request_handle(),
|
|
thread_start_params,
|
|
config.features.get().clone(),
|
|
app_event_tx,
|
|
status_updates,
|
|
managed_requirement,
|
|
services,
|
|
)
|
|
.await?,
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn thread_tool_transport(&self) -> ThreadToolTransport {
|
|
if let Some(server) = self.dynamic_tool_mcp.as_ref() {
|
|
ThreadToolTransport::Mcp(Arc::clone(server))
|
|
} else if self.uses_embedded_app_server() {
|
|
ThreadToolTransport::Disabled
|
|
} else {
|
|
ThreadToolTransport::Dynamic
|
|
}
|
|
}
|
|
|
|
pub(crate) fn with_thread_tool_transport(mut self, transport: ThreadToolTransport) -> Self {
|
|
if let ThreadToolTransport::Mcp(server) = transport {
|
|
self.dynamic_tool_mcp = Some(server);
|
|
}
|
|
self
|
|
}
|
|
|
|
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 | ThreadParamsMode::Cloud
|
|
)
|
|
}
|
|
|
|
pub(crate) fn uses_embedded_app_server(&self) -> bool {
|
|
matches!(&self.client, AppServerClient::InProcess(_))
|
|
}
|
|
|
|
/// Carry capabilities that may exist only in memory when the optional cache is unwritable.
|
|
pub(crate) fn inherit_task_tool_capabilities(&mut self, previous: &Self) {
|
|
self.task_tool_threads.extend(&previous.task_tool_threads);
|
|
}
|
|
|
|
pub(crate) fn task_tools_available(&self, thread_id: ThreadId) -> bool {
|
|
self.task_tool_threads.contains(&thread_id)
|
|
|| self
|
|
.task_tool_capabilities_dir
|
|
.as_ref()
|
|
.is_some_and(|directory| directory.join(thread_id.to_string()).is_file())
|
|
}
|
|
|
|
pub(crate) fn remember_task_tool_thread(&mut self, thread_id: ThreadId) {
|
|
if self.task_tool_threads.insert(thread_id)
|
|
&& let Some(directory) = &self.task_tool_capabilities_dir
|
|
&& let Err(error) = std::fs::create_dir_all(directory)
|
|
.and_then(|()| std::fs::write(directory.join(thread_id.to_string()), []))
|
|
{
|
|
tracing::warn!(%error, %thread_id, "failed to persist task-reference capability");
|
|
}
|
|
}
|
|
|
|
pub(crate) fn task_search_generation(&self) -> Arc<AtomicU64> {
|
|
Arc::clone(&self.task_search_generation)
|
|
}
|
|
|
|
pub(crate) fn codex_home_path(
|
|
&self,
|
|
local_codex_home: &AbsolutePathBuf,
|
|
) -> Option<AppServerPath> {
|
|
self.client.codex_home(local_codex_home)
|
|
}
|
|
|
|
pub(crate) fn server_version(&self) -> Option<&str> {
|
|
let AppServerClient::Remote(client) = &self.client else {
|
|
return None;
|
|
};
|
|
client.server_version()
|
|
}
|
|
|
|
pub(crate) fn server_codex_home(&self) -> Option<&str> {
|
|
let AppServerClient::Remote(client) = &self.client else {
|
|
return None;
|
|
};
|
|
client.codex_home()
|
|
}
|
|
|
|
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
|
|
let started_at = Instant::now();
|
|
let account = self.read_account().await?;
|
|
let mut bootstrap = self.bootstrap_with_account(config, account).await?;
|
|
bootstrap.duration = started_at.elapsed();
|
|
Ok(bootstrap)
|
|
}
|
|
|
|
pub(crate) async fn bootstrap_for_new_thread(
|
|
&mut self,
|
|
config: &Config,
|
|
) -> Result<AppServerBootstrap> {
|
|
let started_at = Instant::now();
|
|
let account = self.read_account().await?;
|
|
let mut bootstrap = self
|
|
.bootstrap_with_account_inner(config, account, EmptyCatalogFallback::ManagedNewThread)
|
|
.await?;
|
|
bootstrap.duration = started_at.elapsed();
|
|
Ok(bootstrap)
|
|
}
|
|
|
|
/// Bootstraps using a previously read account.
|
|
///
|
|
/// Callers must discard a prefetched account after authentication, server, or provider changes.
|
|
pub(crate) async fn bootstrap_with_account(
|
|
&mut self,
|
|
config: &Config,
|
|
account: GetAccountResponse,
|
|
) -> Result<AppServerBootstrap> {
|
|
self.bootstrap_with_account_inner(config, account, EmptyCatalogFallback::Error)
|
|
.await
|
|
}
|
|
|
|
async fn bootstrap_with_account_inner(
|
|
&mut self,
|
|
config: &Config,
|
|
account: GetAccountResponse,
|
|
fallback: EmptyCatalogFallback,
|
|
) -> Result<AppServerBootstrap> {
|
|
let started_at = Instant::now();
|
|
// `hooks/list` holds the global config queue during startup. Submit models and config
|
|
// requirements together so an uncached model fetch can overlap both config requests.
|
|
let model_request_id = self.next_request_id();
|
|
let requirements_request_id = self.next_request_id();
|
|
let (models, requirements, collaboration_modes) = tokio::try_join!(
|
|
async {
|
|
self.client
|
|
.request_typed::<ModelListResponse>(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)
|
|
})
|
|
},
|
|
async {
|
|
match self
|
|
.client
|
|
.request_typed::<ConfigRequirementsReadResponse>(
|
|
ClientRequest::ConfigRequirementsRead {
|
|
request_id: requirements_request_id,
|
|
params: None,
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
Ok(requirements) => Ok(Some(requirements)),
|
|
// A native transport may expose resolved thread permissions without
|
|
// exposing the conventional requirements catalog. Keep that absence
|
|
// distinct from an authoritative response with no requirements.
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if self.thread_params_mode == ThreadParamsMode::Cloud
|
|
&& source.code == -32601 =>
|
|
{
|
|
Ok(None)
|
|
}
|
|
Err(error) => Err(bootstrap_request_error(
|
|
"configRequirements/read failed during TUI bootstrap",
|
|
error,
|
|
)),
|
|
}
|
|
},
|
|
async { Ok(crate::collaboration_modes::list(self.request_handle()).await) },
|
|
)?;
|
|
let mut available_models = models
|
|
.data
|
|
.into_iter()
|
|
.map(model_preset_from_api_model)
|
|
.collect::<Vec<_>>();
|
|
if let Some(requirements) = &requirements {
|
|
crate::service_tier_resolution::constrain_server_service_tiers(
|
|
&mut available_models,
|
|
requirements,
|
|
);
|
|
}
|
|
if matches!(account.account, Some(Account::ApiKey {})) {
|
|
available_models = self
|
|
.constrain_api_key_models(
|
|
config,
|
|
/*thread_id*/ None,
|
|
requirements
|
|
.as_ref()
|
|
.and_then(|response| response.requirements.as_ref()),
|
|
available_models,
|
|
)
|
|
.await;
|
|
}
|
|
let allowed_approval_policies = requirements
|
|
.as_ref()
|
|
.and_then(|response| response.requirements.as_ref())
|
|
.and_then(|requirements| requirements.allowed_approval_policies.clone());
|
|
self.managed_new_thread_defaults = requirements
|
|
.and_then(|response| response.requirements)
|
|
.and_then(|requirements| requirements.models)
|
|
.and_then(|models| models.new_thread);
|
|
let default_model = (self.thread_params_mode.settings_owner()
|
|
== ThreadSettingsOwner::Client)
|
|
.then(|| config.model.clone())
|
|
.flatten()
|
|
.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()))
|
|
.or_else(|| match fallback {
|
|
EmptyCatalogFallback::Error => None,
|
|
EmptyCatalogFallback::ManagedNewThread => self
|
|
.managed_new_thread_defaults
|
|
.as_ref()
|
|
.and_then(|defaults| defaults.model.clone()),
|
|
})
|
|
.wrap_err("No models are available. Set `model` explicitly or check your model catalog configuration.")?;
|
|
self.default_model = Some(default_model.clone());
|
|
self.available_models = available_models.clone();
|
|
|
|
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,
|
|
workspace_name,
|
|
}) => {
|
|
let feedback_audience = if email
|
|
.as_deref()
|
|
.is_some_and(|email| email.ends_with("@openai.com"))
|
|
{
|
|
FeedbackAudience::OpenAiEmployee
|
|
} else {
|
|
FeedbackAudience::External
|
|
};
|
|
(
|
|
email.clone(),
|
|
Some(TelemetryAuthMode::Chatgpt),
|
|
Some(StatusAccountDisplay::ChatGpt {
|
|
email,
|
|
plan: Some(plan_type_display_name(plan_type)),
|
|
workspace: workspace_display(workspace_name, account.workspace_routing),
|
|
}),
|
|
Some(plan_type),
|
|
feedback_audience,
|
|
true,
|
|
)
|
|
}
|
|
Some(Account::AmazonBedrock { .. } | Account::Gcp {}) => {
|
|
(None, None, None, None, FeedbackAudience::External, false)
|
|
}
|
|
None => (None, None, None, None, FeedbackAudience::External, false),
|
|
};
|
|
Ok(AppServerBootstrap {
|
|
duration: started_at.elapsed(),
|
|
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,
|
|
collaboration_modes,
|
|
allowed_approval_policies,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn managed_new_thread_defaults(&self) -> Option<&NewThreadModelDefaults> {
|
|
self.managed_new_thread_defaults.as_ref()
|
|
}
|
|
|
|
pub(crate) fn supports_paginated_history(&self) -> bool {
|
|
self.history_support == ThreadHistorySupport::Paginated
|
|
}
|
|
|
|
/// 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 next_event(&mut self) -> Option<AppServerSessionEvent> {
|
|
self.client
|
|
.next_event()
|
|
.await
|
|
.map(|event| AppServerSessionEvent {
|
|
backend_id: self.backend_id,
|
|
event,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
|
|
self.start_thread_with_session_start_source(
|
|
&LocalSettings::from(config),
|
|
config,
|
|
/*session_start_source*/ None,
|
|
/*remote_cwd_override*/ None,
|
|
/*selected_profile*/ None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn start_thread_with_session_start_source(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: &Config,
|
|
session_start_source: Option<ThreadStartSource>,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
selected_profile: Option<&PermissionProfileSelection>,
|
|
) -> Result<AppServerStartedThread> {
|
|
if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Server
|
|
&& selected_profile.is_some()
|
|
{
|
|
color_eyre::eyre::bail!(
|
|
"Cloud conversations currently use server-selected permissions."
|
|
);
|
|
}
|
|
let request_id = self.next_request_id();
|
|
let session_config = self.session_config_with_effective_service_tier(config);
|
|
let mut params = thread_start_params_from_config(
|
|
&session_config,
|
|
self.thread_params_mode(),
|
|
remote_cwd_override.or(self.remote_cwd_override.as_deref()),
|
|
session_start_source,
|
|
);
|
|
if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Client {
|
|
params.model_provider = self
|
|
.model_provider_override
|
|
.clone()
|
|
.or(params.model_provider);
|
|
params.daybreak_enabled =
|
|
(config.daybreak_enabled && !config.ephemeral).then_some(true);
|
|
}
|
|
if let Some(selected_profile) = selected_profile {
|
|
params.runtime_workspace_roots = None;
|
|
params.permissions = Some(selected_profile.profile_id.clone());
|
|
params.sandbox = None;
|
|
params.approval_policy = selected_profile.approval_policy;
|
|
params.approvals_reviewer = selected_profile.approvals_reviewer.map(Into::into);
|
|
remove_permission_config_overrides(&mut params.config);
|
|
}
|
|
if self.history_support == ThreadHistorySupport::LegacyOnly {
|
|
params.history_mode = None;
|
|
}
|
|
if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Client {
|
|
self.thread_tool_transport().validate_config(config)?;
|
|
self.thread_tool_transport().configure(&mut params);
|
|
}
|
|
let request_handle = self.request_handle();
|
|
let (response, history_support, task_tools_available) =
|
|
request_thread_start_with_history_fallback(&request_handle, request_id, params)
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
|
|
})?;
|
|
if history_support == ThreadHistorySupport::LegacyOnly {
|
|
self.history_support = ThreadHistorySupport::LegacyOnly;
|
|
}
|
|
// An explicit server selection must not reuse the client's local display policy.
|
|
let selected_permissions = selected_profile.map(|_| {
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
|
|
&response.sandbox.to_core(),
|
|
response.cwd.as_path(),
|
|
)
|
|
});
|
|
let mut started = started_thread_from_start_response(
|
|
response,
|
|
local_settings,
|
|
config,
|
|
self.thread_params_mode(),
|
|
)
|
|
.await?;
|
|
if let Some(permissions) = selected_permissions {
|
|
started.session.permission_profile = permissions;
|
|
}
|
|
started.task_tools_available = task_tools_available;
|
|
if task_tools_available {
|
|
self.remember_task_tool_thread(started.session.thread_id);
|
|
}
|
|
Ok(started)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn fork_thread(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<AppServerStartedThread> {
|
|
self.fork_thread_with_permission_mode(
|
|
local_settings,
|
|
config,
|
|
thread_id,
|
|
ForkPermissionMode::InheritSaved,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn fork_thread_with_permission_mode(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
permission_mode: ForkPermissionMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
self.fork_thread_at_with_presentation(
|
|
local_settings,
|
|
config,
|
|
thread_id,
|
|
/*last_turn_id*/ None,
|
|
/*before_turn_id*/ None,
|
|
ForkGoalContinuation::StartIfIdle,
|
|
ForkPresentation::Regular,
|
|
/*selected_profile*/ None,
|
|
permission_mode,
|
|
ForkConfigSource::Local,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "fork position and named permissions are independent"
|
|
)]
|
|
pub(crate) async fn fork_thread_at(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
last_turn_id: Option<String>,
|
|
before_turn_id: Option<String>,
|
|
goal_continuation: ForkGoalContinuation,
|
|
selected_profile: Option<&PermissionProfileSelection>,
|
|
) -> Result<AppServerStartedThread> {
|
|
self.fork_thread_at_with_presentation(
|
|
local_settings,
|
|
config,
|
|
thread_id,
|
|
last_turn_id,
|
|
before_turn_id,
|
|
goal_continuation,
|
|
ForkPresentation::Regular,
|
|
selected_profile,
|
|
ForkPermissionMode::InheritSaved,
|
|
ForkConfigSource::Session,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn fork_side_thread(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
selected_profile: Option<&PermissionProfileSelection>,
|
|
) -> Result<AppServerStartedThread> {
|
|
self.fork_thread_at_with_presentation(
|
|
local_settings,
|
|
config,
|
|
thread_id,
|
|
/*last_turn_id*/ None,
|
|
/*before_turn_id*/ None,
|
|
ForkGoalContinuation::StartIfIdle,
|
|
ForkPresentation::SideConversation,
|
|
selected_profile,
|
|
ForkPermissionMode::InheritSaved,
|
|
ForkConfigSource::Session,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "keep local preferences separate while the legacy Config parameter is still required"
|
|
)]
|
|
async fn fork_thread_at_with_presentation(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
last_turn_id: Option<String>,
|
|
before_turn_id: Option<String>,
|
|
goal_continuation: ForkGoalContinuation,
|
|
presentation: ForkPresentation,
|
|
selected_profile: Option<&PermissionProfileSelection>,
|
|
permission_mode: ForkPermissionMode,
|
|
config_source: ForkConfigSource,
|
|
) -> Result<AppServerStartedThread> {
|
|
if self.thread_params_mode == ThreadParamsMode::Cloud {
|
|
color_eyre::eyre::bail!("Cloud conversations cannot be forked in the TUI yet.");
|
|
}
|
|
self.thread_tool_transport().validate_config(&config)?;
|
|
let fork_parent = match presentation {
|
|
ForkPresentation::Regular => self
|
|
.thread_read(thread_id, /*include_turns*/ false)
|
|
.await
|
|
.ok(),
|
|
ForkPresentation::SideConversation => None,
|
|
};
|
|
let exclude_turns = self.history_support == ThreadHistorySupport::Paginated
|
|
&& (fork_parent
|
|
.as_ref()
|
|
.is_some_and(|thread| thread.history_mode == ThreadHistoryMode::Paginated)
|
|
|| presentation == ForkPresentation::SideConversation);
|
|
let request_id = self.next_request_id();
|
|
let session_config = if config.model.is_none() {
|
|
// Avoid inferring a tier from the stale client default model.
|
|
config.clone()
|
|
} else {
|
|
self.session_config_with_effective_service_tier(&config)
|
|
};
|
|
let mut params = ThreadForkParams {
|
|
last_turn_id,
|
|
before_turn_id,
|
|
defer_goal_continuation: goal_continuation == ForkGoalContinuation::DeferUntilNextTurn,
|
|
exclude_turns,
|
|
..thread_fork_params_from_config(
|
|
session_config,
|
|
thread_id,
|
|
self.thread_params_mode(),
|
|
self.remote_cwd_override.as_deref(),
|
|
)
|
|
};
|
|
params.model_provider = match config_source {
|
|
ForkConfigSource::Local => self
|
|
.model_provider_override
|
|
.clone()
|
|
.or(params.model_provider),
|
|
ForkConfigSource::Session => Some(config.model_provider_id.clone()),
|
|
};
|
|
if config_source == ForkConfigSource::Session {
|
|
// Active-session config has already adopted the server's roots. Fork does
|
|
// not restore saved roots when omitted, so preserve this explicit selection.
|
|
params.runtime_workspace_roots = Some(config.workspace_roots.clone());
|
|
}
|
|
if self.thread_params_mode() == ThreadParamsMode::Remote
|
|
&& permission_mode == ForkPermissionMode::InheritSaved
|
|
&& selected_profile.is_none()
|
|
{
|
|
params.approval_policy = None;
|
|
params.approvals_reviewer = None;
|
|
params.sandbox = None;
|
|
params.permissions = None;
|
|
remove_permission_config_overrides(&mut params.config);
|
|
} else if let Some(selected_profile) = selected_profile {
|
|
params.runtime_workspace_roots = None;
|
|
params.permissions = Some(selected_profile.profile_id.clone());
|
|
params.sandbox = None;
|
|
params.approval_policy = selected_profile.approval_policy;
|
|
params.approvals_reviewer = selected_profile.approvals_reviewer.map(Into::into);
|
|
remove_permission_config_overrides(&mut params.config);
|
|
}
|
|
self.thread_tool_transport()
|
|
.configure_mcp(&mut params.config);
|
|
let response: ThreadForkResponse = match self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: params.clone(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => response,
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if params.exclude_turns && is_history_pagination_unsupported(&source) =>
|
|
{
|
|
self.history_support = ThreadHistorySupport::LegacyOnly;
|
|
params.exclude_turns = false;
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadFork { request_id, params })
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/fork failed during TUI bootstrap", err)
|
|
})?
|
|
}
|
|
Err(err) => {
|
|
return Err(bootstrap_request_error(
|
|
"thread/fork failed during TUI bootstrap",
|
|
err,
|
|
));
|
|
}
|
|
};
|
|
let mut response = response;
|
|
if presentation == ForkPresentation::Regular
|
|
&& !response.thread.ephemeral
|
|
&& let Err(error) = self
|
|
.hydrate_initial_thread_history(
|
|
&mut response.thread,
|
|
/*turn_cursor*/ None,
|
|
/*item_cursor*/ None,
|
|
Some(&config),
|
|
Some(local_settings),
|
|
HistoryHydrationScope::Initial,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
thread_id = %response.thread.id,
|
|
error = %error,
|
|
"preserving the created fork after bounded history hydration failed"
|
|
);
|
|
}
|
|
// Explicit selections use the server's effective policy, including on local daemons.
|
|
let selected_permissions = selected_profile.map(|_| {
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
|
|
&response.sandbox.to_core(),
|
|
response.cwd.as_path(),
|
|
)
|
|
});
|
|
let mut started = started_thread_from_fork_response(
|
|
response,
|
|
local_settings,
|
|
&config,
|
|
self.thread_params_mode(),
|
|
)
|
|
.await?;
|
|
if let Some(permissions) = selected_permissions {
|
|
started.session.permission_profile = permissions;
|
|
}
|
|
if presentation == ForkPresentation::SideConversation {
|
|
started.session.daybreak_enabled = false;
|
|
}
|
|
started.session.fork_parent_title = fork_parent.and_then(|thread| thread.name);
|
|
if self.task_tools_available(thread_id) {
|
|
started.task_tools_available = true;
|
|
self.remember_task_tool_thread(started.session.thread_id);
|
|
}
|
|
Ok(started)
|
|
}
|
|
|
|
pub(crate) fn thread_params_mode(&self) -> ThreadParamsMode {
|
|
self.thread_params_mode
|
|
}
|
|
|
|
fn session_config_with_effective_service_tier(&self, config: &Config) -> Config {
|
|
let Some(model) = config.model.as_deref().or(self.default_model.as_deref()) else {
|
|
return config.clone();
|
|
};
|
|
let local_settings = LocalSettings::from(config);
|
|
let mut session_config = config.clone();
|
|
match service_tier_resolution::service_tier_update_for_core(
|
|
config,
|
|
&local_settings.notices,
|
|
model,
|
|
&self.available_models,
|
|
) {
|
|
Some(Some(service_tier)) => {
|
|
session_config.service_tier = Some(service_tier);
|
|
session_config.notices.fast_default_opt_out = None;
|
|
}
|
|
Some(None) => {
|
|
session_config.service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string());
|
|
session_config.notices.fast_default_opt_out = None;
|
|
}
|
|
None => {
|
|
session_config.service_tier = None;
|
|
session_config.notices.fast_default_opt_out = None;
|
|
}
|
|
}
|
|
session_config
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 = self
|
|
.client
|
|
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
|
|
request_id,
|
|
params: ThreadReadParams {
|
|
thread_id: thread_id.to_string(),
|
|
include_turns,
|
|
},
|
|
})
|
|
.await;
|
|
let mut response: ThreadReadResponse = match response {
|
|
Ok(response) => return Ok(response.thread),
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if include_turns
|
|
&& source.message
|
|
== "paginated threads do not support thread/read(includeTurns=true)" =>
|
|
{
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadRead {
|
|
request_id,
|
|
params: ThreadReadParams {
|
|
thread_id: thread_id.to_string(),
|
|
include_turns: false,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/read failed during TUI session lookup")?
|
|
}
|
|
Err(err) => return Err(err).wrap_err("thread/read failed during TUI session lookup"),
|
|
};
|
|
self.hydrate_initial_thread_history(
|
|
&mut response.thread,
|
|
/*turn_cursor*/ None,
|
|
/*item_cursor*/ None,
|
|
/*config*/ None,
|
|
/*local_settings*/ None,
|
|
HistoryHydrationScope::Initial,
|
|
)
|
|
.await?;
|
|
Ok(response.thread)
|
|
}
|
|
|
|
pub(crate) async fn thread_archive(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadArchiveResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("failed to archive session")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_delete(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadDeleteResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadDelete {
|
|
request_id,
|
|
params: ThreadDeleteParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("failed to delete session")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_unarchive(&mut self, thread_id: ThreadId) -> Result<Thread> {
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadUnarchiveResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadUnarchive {
|
|
request_id,
|
|
params: ThreadUnarchiveParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("failed to unarchive session")?;
|
|
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(),
|
|
project_id: None,
|
|
daybreak_enabled: None,
|
|
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<bool> {
|
|
if !self.thread_settings_update_supported {
|
|
return Ok(false);
|
|
}
|
|
let request_id = self.next_request_id();
|
|
match self
|
|
.client
|
|
.request_typed::<ThreadSettingsUpdateResponse>(ClientRequest::ThreadSettingsUpdate {
|
|
request_id,
|
|
params,
|
|
})
|
|
.await
|
|
{
|
|
Ok(_) => Ok(true),
|
|
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, or mode.
|
|
self.thread_settings_update_supported = false;
|
|
Ok(false)
|
|
}
|
|
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,
|
|
turn_trigger: &str,
|
|
client_user_message_id: String,
|
|
items: Vec<UserInput>,
|
|
cwd: PathBuf,
|
|
approval_policy: Option<AskForApproval>,
|
|
approvals_reviewer: Option<codex_app_server_protocol::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>,
|
|
output_schema: Option<serde_json::Value>,
|
|
cyber_access_program: Option<codex_app_server_protocol::CyberAccessProgram>,
|
|
) -> Result<TurnStartResponse> {
|
|
let request_id = self.next_request_id();
|
|
let mut params = TurnStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_trigger: Some(turn_trigger.to_string()),
|
|
client_user_message_id: Some(client_user_message_id),
|
|
input: items,
|
|
output_schema,
|
|
cyber_access_program,
|
|
..TurnStartParams::default()
|
|
};
|
|
if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Client {
|
|
let (sandbox_policy, permissions) =
|
|
turn_permissions_overrides(permissions_override, cwd.as_path())?;
|
|
params.cwd = Some(cwd);
|
|
params.runtime_workspace_roots = Some(workspace_roots.to_vec());
|
|
params.approval_policy = approval_policy;
|
|
params.approvals_reviewer = approvals_reviewer;
|
|
params.sandbox_policy = sandbox_policy;
|
|
params.permissions = permissions;
|
|
params.model = Some(model);
|
|
params.service_tier = service_tier;
|
|
params.effort = effort;
|
|
params.summary = summary;
|
|
params.collaboration_mode = collaboration_mode;
|
|
}
|
|
self.client
|
|
.request_typed(ClientRequest::TurnStart { request_id, params })
|
|
.await
|
|
.wrap_err("turn/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn turn_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
) -> std::result::Result<(), TypedRequestError> {
|
|
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?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn startup_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> std::result::Result<(), TypedRequestError> {
|
|
self.turn_interrupt(thread_id, String::new()).await
|
|
}
|
|
|
|
pub(crate) async fn turn_steer(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
client_user_message_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(),
|
|
client_user_message_id: Some(client_user_message_id),
|
|
input: items,
|
|
responsesapi_client_metadata: None,
|
|
responsesapi_client_metadata_patch: None,
|
|
additional_context: 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 {
|
|
origin: Some(codex_app_server_protocol::ThreadGoalMutationOrigin::User),
|
|
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 {
|
|
origin: Some(codex_app_server_protocol::ThreadGoalMutationOrigin::User),
|
|
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,
|
|
timeout_ms: None,
|
|
},
|
|
})
|
|
.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 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 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()
|
|
}
|
|
|
|
pub(crate) 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,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<PathBuf>,
|
|
thread_tool_transport: ThreadToolTransport,
|
|
launch_choices: StartupLaunchChoices,
|
|
) -> Result<AppServerStartedThread> {
|
|
let request_id = RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4()));
|
|
let mut params = thread_start_params_with_reviewer(
|
|
&config,
|
|
thread_params_mode,
|
|
remote_cwd_override.as_deref(),
|
|
/*session_start_source*/ None,
|
|
launch_choices.approvals_reviewer,
|
|
&launch_choices.approval_policy,
|
|
);
|
|
if thread_params_mode.settings_owner() == ThreadSettingsOwner::Client {
|
|
thread_tool_transport.validate_config(&config)?;
|
|
launch_choices.configure(&mut params);
|
|
params.daybreak_enabled = (config.daybreak_enabled && !config.ephemeral).then_some(true);
|
|
thread_tool_transport.configure(&mut params);
|
|
}
|
|
let (response, _history_support, task_tools_available) =
|
|
request_thread_start_with_history_fallback(&request_handle, request_id, params)
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
|
|
})?;
|
|
let mut started =
|
|
started_thread_from_start_response(response, local_settings, &config, thread_params_mode)
|
|
.await?;
|
|
started.task_tools_available = task_tools_available;
|
|
Ok(started)
|
|
}
|
|
|
|
pub(crate) fn workspace_display(
|
|
name: Option<String>,
|
|
routing: Option<codex_app_server_protocol::WorkspaceRouting>,
|
|
) -> Option<String> {
|
|
let name = name
|
|
.map(|name| name.trim().to_owned())
|
|
.filter(|name| !name.is_empty() && !name.chars().any(char::is_control));
|
|
let id = routing.map(|routing| routing.chatgpt_account_id);
|
|
match (name, id) {
|
|
(Some(name), Some(id)) => Some(format!("{name} ({id})")),
|
|
(name, id) => name.or(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(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt {
|
|
email: None,
|
|
plan: plan_type.map(plan_type_display_name),
|
|
workspace: None,
|
|
}),
|
|
Some(AuthMode::Headers)
|
|
| Some(AuthMode::BedrockApiKey)
|
|
| Some(AuthMode::BedrockAccessKeys) => None,
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) 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,
|
|
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),
|
|
retirement_at: model
|
|
.upgrade_info
|
|
.as_ref()
|
|
.and_then(|info| info.retirement_at)
|
|
.and_then(|retirement_at| {
|
|
chrono::DateTime::<chrono::Utc>::from_timestamp(retirement_at, 0)
|
|
}),
|
|
}
|
|
});
|
|
|
|
ModelPreset {
|
|
id: model.id,
|
|
model: model.model,
|
|
display_name: model.display_name,
|
|
description: model.description,
|
|
model_specialty: model.model_specialty,
|
|
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: false,
|
|
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(),
|
|
default_service_tier: model.default_service_tier,
|
|
available_access_programs: model.available_access_programs.map(Into::into),
|
|
is_default: model.is_default,
|
|
upgrade,
|
|
show_in_picker: !model.hidden,
|
|
multi_agent_version: None,
|
|
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,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Option<HashMap<String, serde_json::Value>> {
|
|
let mut session_config = toml::Value::Table(toml::Table::new());
|
|
for layer in config.config_layer_stack.layers_low_to_high() {
|
|
if matches!(&layer.name, ConfigLayerSource::SessionFlags) {
|
|
codex_config::merge_toml_values(&mut session_config, &layer.config);
|
|
}
|
|
}
|
|
let mut overrides: HashMap<_, _> = session_config
|
|
.as_table()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|(key, _)| {
|
|
matches!(
|
|
key.as_str(),
|
|
"allow_login_shell"
|
|
| "default_permissions"
|
|
| "features"
|
|
| "network"
|
|
| "permissions"
|
|
| "personality"
|
|
| "sandbox_workspace_write"
|
|
| "shell_environment_policy"
|
|
| "suppress_unstable_features_warning"
|
|
)
|
|
})
|
|
.filter_map(|(key, value)| {
|
|
serde_json::to_value(value)
|
|
.ok()
|
|
.map(|value| (key.clone(), value))
|
|
})
|
|
.collect();
|
|
let mut insert = |key: &str, value: Option<String>| {
|
|
if let Some(value) = value {
|
|
overrides.insert(key.to_string(), serde_json::Value::String(value));
|
|
}
|
|
};
|
|
insert(
|
|
"model_reasoning_effort",
|
|
config
|
|
.model_reasoning_effort
|
|
.as_ref()
|
|
.map(std::string::ToString::to_string),
|
|
);
|
|
// Only winning launch choices may replace server defaults or saved thread settings.
|
|
let origins = config.config_layer_stack.origins();
|
|
let effective = config.config_layer_stack.effective_config();
|
|
web_search::apply_launch_override(
|
|
config,
|
|
thread_params_mode,
|
|
&effective,
|
|
&origins,
|
|
&mut overrides,
|
|
);
|
|
let is_launch = |key: &str| {
|
|
origins.get(key).is_some_and(|origin| {
|
|
matches!(
|
|
origin.name,
|
|
ConfigLayerSource::SessionFlags
|
|
| ConfigLayerSource::User {
|
|
profile: Some(_),
|
|
..
|
|
}
|
|
)
|
|
})
|
|
};
|
|
for key in ["model_reasoning_summary", "model_verbosity"] {
|
|
if is_launch(key) {
|
|
overrides.insert(key.to_string(), serde_json::json!(effective[key]));
|
|
}
|
|
}
|
|
if is_launch("features.concurrent_reasoning_summaries") {
|
|
overrides
|
|
.entry("features".to_string())
|
|
.or_insert_with(|| serde_json::json!({}))["concurrent_reasoning_summaries"] =
|
|
serde_json::json!(effective["features"]["concurrent_reasoning_summaries"]);
|
|
}
|
|
if config.bypass_hook_trust {
|
|
overrides.insert("bypass_hook_trust".to_string(), true.into());
|
|
}
|
|
Some(overrides)
|
|
}
|
|
|
|
fn remove_permission_config_overrides(config: &mut Option<HashMap<String, serde_json::Value>>) {
|
|
if let Some(overrides) = config.as_mut() {
|
|
for key in [
|
|
"default_permissions",
|
|
"permissions",
|
|
"network",
|
|
"sandbox_workspace_write",
|
|
] {
|
|
overrides.remove(key);
|
|
}
|
|
}
|
|
if config.as_ref().is_some_and(HashMap::is_empty) {
|
|
*config = None;
|
|
}
|
|
}
|
|
|
|
fn service_tier_override_from_config(config: &Config) -> Option<Option<String>> {
|
|
let local_settings = LocalSettings::from(config);
|
|
config.service_tier.clone().map(Some).or_else(|| {
|
|
(local_settings.notices.fast_default_opt_out == Some(true))
|
|
.then(|| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
|
|
})
|
|
}
|
|
|
|
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_local_path_with_cwd(cwd, cwd) {
|
|
Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
|
|
} else {
|
|
Some(codex_app_server_protocol::SandboxMode::ReadOnly)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn permission_profile_is_safely_represented_by_sandbox_mode(
|
|
permission_profile: &PermissionProfile,
|
|
cwd: &std::path::Path,
|
|
) -> bool {
|
|
let Some(sandbox_mode) = sandbox_mode_from_permission_profile(permission_profile, cwd) else {
|
|
return false;
|
|
};
|
|
let projected_profile = match sandbox_mode {
|
|
codex_app_server_protocol::SandboxMode::ReadOnly => PermissionProfile::read_only(),
|
|
codex_app_server_protocol::SandboxMode::WorkspaceWrite => {
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
|
|
&codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(),
|
|
cwd,
|
|
)
|
|
}
|
|
codex_app_server_protocol::SandboxMode::DangerFullAccess => PermissionProfile::Disabled,
|
|
};
|
|
permission_profile.network_sandbox_policy() == projected_profile.network_sandbox_policy()
|
|
&& permission_profile
|
|
.file_system_sandbox_policy()
|
|
.is_semantically_equivalent_to(&projected_profile.file_system_sandbox_policy(), cwd)
|
|
}
|
|
|
|
fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> String {
|
|
active.id
|
|
}
|
|
|
|
pub(crate) fn turn_permissions_overrides(
|
|
permissions_override: TurnPermissionsOverride,
|
|
cwd: &std::path::Path,
|
|
) -> Result<(
|
|
Option<codex_app_server_protocol::SandboxPolicy>,
|
|
Option<String>,
|
|
)> {
|
|
Ok(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 policy = permission_profile
|
|
.to_legacy_sandbox_policy(cwd)
|
|
.map_err(|_| UnsupportedLegacyPermissionProfile)?;
|
|
let projected_profile =
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd);
|
|
if !permission_profile
|
|
.file_system_sandbox_policy()
|
|
.is_semantically_equivalent_to(&projected_profile.file_system_sandbox_policy(), cwd)
|
|
{
|
|
return Err(UnsupportedLegacyPermissionProfile.into());
|
|
}
|
|
(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)
|
|
}
|
|
|
|
pub(crate) 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 {
|
|
thread_start_params_with_reviewer(
|
|
config,
|
|
thread_params_mode,
|
|
remote_cwd_override,
|
|
session_start_source,
|
|
startup_launch::StartupApprovalsReviewer::Config,
|
|
&startup_launch::StartupApprovalPolicy::ResolvedConfig,
|
|
)
|
|
}
|
|
|
|
fn thread_start_params_with_reviewer(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
session_start_source: Option<ThreadStartSource>,
|
|
reviewer_source: startup_launch::StartupApprovalsReviewer,
|
|
policy_source: &startup_launch::StartupApprovalPolicy,
|
|
) -> ThreadStartParams {
|
|
if thread_params_mode.settings_owner() == ThreadSettingsOwner::Server {
|
|
return ThreadStartParams::default();
|
|
}
|
|
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: provider_selection::explicit_provider(config),
|
|
service_tier: service_tier_override_from_config(config),
|
|
cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(config),
|
|
approval_policy: match policy_source {
|
|
startup_launch::StartupApprovalPolicy::ResolvedConfig => {
|
|
Some(config.permissions.approval_policy.value().into())
|
|
}
|
|
startup_launch::StartupApprovalPolicy::Invocation(policy) => *policy,
|
|
startup_launch::StartupApprovalPolicy::ConfigOverride(_) => None,
|
|
},
|
|
approvals_reviewer: match reviewer_source {
|
|
startup_launch::StartupApprovalsReviewer::Config => {
|
|
approvals_reviewer_override_from_config(config)
|
|
}
|
|
startup_launch::StartupApprovalsReviewer::Invocation(reviewer) => reviewer,
|
|
},
|
|
sandbox,
|
|
permissions,
|
|
config: config_request_overrides_from_config(config, thread_params_mode),
|
|
ephemeral: Some(config.ephemeral),
|
|
history_mode: (!config.ephemeral).then_some(ThreadHistoryMode::Paginated),
|
|
session_start_source,
|
|
thread_source: Some(ThreadSource::User),
|
|
developer_instructions: with_terminal_visualization_instructions(
|
|
config, /*control_instructions*/ None,
|
|
),
|
|
..ThreadStartParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_resume_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
model_settings: ResumeModelSettings,
|
|
permission_overrides: crate::resume_permissions::ResumePermissions,
|
|
) -> ThreadResumeParams {
|
|
if thread_params_mode.settings_owner() == ThreadSettingsOwner::Server
|
|
|| model_settings == ResumeModelSettings::PreserveExistingThread
|
|
{
|
|
return ThreadResumeParams {
|
|
thread_id: thread_id.to_string(),
|
|
..ThreadResumeParams::default()
|
|
};
|
|
}
|
|
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();
|
|
let mut config_overrides = config_request_overrides_from_config(&config, thread_params_mode);
|
|
if model_settings == ResumeModelSettings::RestoreFromThread
|
|
&& let Some(overrides) = config_overrides.as_mut()
|
|
{
|
|
overrides.remove("model_reasoning_effort");
|
|
if overrides.is_empty() {
|
|
config_overrides = None;
|
|
}
|
|
}
|
|
let (model, model_provider) = match model_settings {
|
|
ResumeModelSettings::OverrideFromCurrentConfig => (
|
|
config.model.clone(),
|
|
provider_selection::explicit_provider(&config),
|
|
),
|
|
ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread => {
|
|
(None, None)
|
|
}
|
|
};
|
|
let mut params = ThreadResumeParams {
|
|
thread_id: thread_id.to_string(),
|
|
model,
|
|
model_provider,
|
|
service_tier: service_tier_override_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(&config),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(&config),
|
|
sandbox,
|
|
permissions,
|
|
config: config_overrides,
|
|
developer_instructions: with_terminal_visualization_instructions(
|
|
&config, /*control_instructions*/ None,
|
|
),
|
|
..ThreadResumeParams::default()
|
|
};
|
|
if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.approval_policy {
|
|
params.approval_policy = None;
|
|
}
|
|
if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.approvals_reviewer {
|
|
params.approvals_reviewer = None;
|
|
}
|
|
if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.profile {
|
|
params.sandbox = None;
|
|
params.permissions = None;
|
|
remove_permission_config_overrides(&mut params.config);
|
|
}
|
|
if !permission_overrides.workspace_roots {
|
|
params.runtime_workspace_roots = None;
|
|
}
|
|
params
|
|
}
|
|
|
|
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: provider_selection::explicit_provider(&config),
|
|
service_tier: service_tier_override_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(&config),
|
|
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, thread_params_mode),
|
|
base_instructions: config.base_instructions.clone().filter(|_| {
|
|
!matches!(
|
|
config.base_instructions_provenance,
|
|
Some(BaseInstructionsProvenance::Model { .. })
|
|
)
|
|
}),
|
|
developer_instructions: with_terminal_visualization_instructions(
|
|
&config,
|
|
config.developer_instructions.clone(),
|
|
),
|
|
ephemeral: config.ephemeral,
|
|
thread_source: Some(ThreadSource::User),
|
|
..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 | ThreadParamsMode::Cloud => {
|
|
remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn started_thread_from_start_response(
|
|
response: ThreadStartResponse,
|
|
local_settings: &LocalSettings,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
|
|
let session = thread_session_state_from_thread_start_response(
|
|
&response,
|
|
local_settings,
|
|
config,
|
|
thread_params_mode,
|
|
)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
blocks_direct_input,
|
|
task_tools_available: false,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_resume_response(
|
|
response: ThreadResumeResponse,
|
|
local_settings: &LocalSettings,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
|
|
let session = thread_session_state_from_thread_resume_response(
|
|
&response,
|
|
local_settings,
|
|
thread_params_mode,
|
|
)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
blocks_direct_input,
|
|
task_tools_available: false,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_fork_response(
|
|
response: ThreadForkResponse,
|
|
local_settings: &LocalSettings,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
|
|
let session = thread_session_state_from_thread_fork_response(
|
|
&response,
|
|
local_settings,
|
|
config,
|
|
thread_params_mode,
|
|
)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
blocks_direct_input,
|
|
task_tools_available: false,
|
|
})
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_start_response(
|
|
response: &ThreadStartResponse,
|
|
local_settings: &LocalSettings,
|
|
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,
|
|
);
|
|
let mut session = thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()),
|
|
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_source_path_uris(),
|
|
response.reasoning_effort.clone(),
|
|
local_settings,
|
|
)
|
|
.await?;
|
|
session.daybreak_enabled = response
|
|
.thread
|
|
.daybreak_enabled
|
|
.unwrap_or(response.thread.ephemeral && config.daybreak_enabled);
|
|
Ok(session)
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_resume_response(
|
|
response: &ThreadResumeResponse,
|
|
local_settings: &LocalSettings,
|
|
_thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
// The server owns restored rules. This legacy projection is for display only;
|
|
// normal turns do not send it back as an execution override.
|
|
let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd(
|
|
&response.sandbox.to_core(),
|
|
response.cwd.as_path(),
|
|
);
|
|
let mut session = thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()),
|
|
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_source_path_uris(),
|
|
response.reasoning_effort.clone(),
|
|
local_settings,
|
|
)
|
|
.await?;
|
|
session.collaboration_mode = response.collaboration_mode.clone().map(Box::new);
|
|
session.daybreak_enabled = response.thread.daybreak_enabled.unwrap_or(false);
|
|
Ok(session)
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_fork_response(
|
|
response: &ThreadForkResponse,
|
|
local_settings: &LocalSettings,
|
|
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,
|
|
);
|
|
let mut session = thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()),
|
|
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_source_path_uris(),
|
|
response.reasoning_effort.clone(),
|
|
local_settings,
|
|
)
|
|
.await?;
|
|
session.daybreak_enabled = response.thread.daybreak_enabled.unwrap_or(false);
|
|
Ok(session)
|
|
}
|
|
|
|
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
|
|
if matches!(
|
|
config.permissions.effective_permission_profile(),
|
|
PermissionProfile::Disabled
|
|
) && !matches!(
|
|
sandbox,
|
|
codex_app_server_protocol::SandboxPolicy::DangerFullAccess
|
|
) =>
|
|
{
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd)
|
|
}
|
|
ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(),
|
|
ThreadParamsMode::Remote | ThreadParamsMode::Cloud => {
|
|
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,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost,
|
|
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<PathUri>,
|
|
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
|
|
local_settings: &LocalSettings,
|
|
) -> 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(
|
|
local_settings.codex_home.clone(),
|
|
&local_settings.history,
|
|
);
|
|
let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await;
|
|
Ok(ThreadSessionState {
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host,
|
|
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,
|
|
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)]
|
|
#[path = "app_server_session/reasoning_defaults_tests.rs"]
|
|
mod reasoning_defaults_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "app_server_session/workspace_roots_tests.rs"]
|
|
mod workspace_roots_tests;
|
|
|
|
#[cfg(test)]
|
|
#[path = "app_server_session/prompt_history_tests.rs"]
|
|
mod prompt_history_tests;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::legacy_core::config::ConfigBuilder;
|
|
use crate::legacy_core::config::ConfigOverrides;
|
|
use app_test_support::create_fake_paginated_rollout;
|
|
use app_test_support::create_fake_rollout;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_features::Feature;
|
|
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::ModelAccessPrograms;
|
|
use codex_protocol::openai_models::ModelServiceTier;
|
|
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_protocol::turn_input::CyberAccessProgram;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use codex_utils_path_uri::LegacyAppPathString;
|
|
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 bootstrap_reuses_prefetched_account_without_another_account_read() -> Result<()> {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let mut config = build_config(&codex_home).await;
|
|
let mut catalog = codex_models_manager::bundled_models_response()?;
|
|
for model in &mut catalog.models {
|
|
model.available_access_programs = Some(ModelAccessPrograms {
|
|
cyber: vec![
|
|
CyberAccessProgram::Standard,
|
|
CyberAccessProgram::DaybreakBlue,
|
|
],
|
|
});
|
|
}
|
|
config.model_catalog = Some(catalog);
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
let next_request_id = app_server.next_request_id;
|
|
let account = GetAccountResponse {
|
|
workspace_routing: None,
|
|
account: Some(Account::Chatgpt {
|
|
email: Some("teammate@openai.com".to_string()),
|
|
plan_type: codex_protocol::account::PlanType::Plus,
|
|
workspace_name: None,
|
|
}),
|
|
requires_openai_auth: true,
|
|
};
|
|
|
|
let bootstrap = app_server.bootstrap_with_account(&config, account).await?;
|
|
|
|
assert_eq!(app_server.next_request_id, next_request_id + 2);
|
|
assert_eq!(
|
|
(
|
|
bootstrap.account_email.as_deref(),
|
|
bootstrap.auth_mode,
|
|
bootstrap.plan_type,
|
|
bootstrap.feedback_audience,
|
|
bootstrap.has_chatgpt_account,
|
|
),
|
|
(
|
|
Some("teammate@openai.com"),
|
|
Some(TelemetryAuthMode::Chatgpt),
|
|
Some(codex_protocol::account::PlanType::Plus),
|
|
FeedbackAudience::OpenAiEmployee,
|
|
true,
|
|
)
|
|
);
|
|
|
|
// Account-specific catalog filtering at startup and before/after thread creation.
|
|
let models = bootstrap.available_models;
|
|
assert!(
|
|
models
|
|
.iter()
|
|
.any(|model| model.available_access_programs.is_some())
|
|
);
|
|
let client_home = tempfile::tempdir()?;
|
|
let key = "features.api_key_cyber_access_programs";
|
|
for (server_enabled, cli_override, forwarding_enabled) in [
|
|
(false, None, false),
|
|
(true, None, true),
|
|
(false, Some(true), true),
|
|
(true, Some(false), false),
|
|
] {
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!("[features]\napi_key_cyber_access_programs = {server_enabled}\n"),
|
|
)?;
|
|
let client_config = ConfigBuilder::default()
|
|
.codex_home(client_home.path().to_path_buf())
|
|
.cli_overrides(
|
|
cli_override
|
|
.into_iter()
|
|
.map(|value| (key.into(), value.into()))
|
|
.collect(),
|
|
)
|
|
.build()
|
|
.await?;
|
|
let api_key = app_server
|
|
.bootstrap_with_account(
|
|
&client_config,
|
|
GetAccountResponse {
|
|
account: Some(Account::ApiKey {}),
|
|
requires_openai_auth: true,
|
|
workspace_routing: None,
|
|
},
|
|
)
|
|
.await?;
|
|
let mut expected = models.clone();
|
|
if !forwarding_enabled {
|
|
for model in &mut expected {
|
|
model.available_access_programs = None;
|
|
}
|
|
}
|
|
assert_eq!(api_key.available_models, expected);
|
|
let thread = app_server.start_thread(&client_config).await?;
|
|
let refreshed = app_server
|
|
.constrain_api_key_models(
|
|
&client_config,
|
|
Some(thread.session.thread_id),
|
|
/*requirements*/ None,
|
|
models.clone(),
|
|
)
|
|
.await;
|
|
assert_eq!(refreshed, expected);
|
|
}
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bootstrap_reads_account_when_no_prefetched_account_is_available() -> Result<()> {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let config = build_config(&codex_home).await;
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
let next_request_id = app_server.next_request_id;
|
|
|
|
app_server.bootstrap(&config).await?;
|
|
|
|
assert_eq!(app_server.next_request_id, next_request_id + 3);
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cloud_bootstrap_uses_server_models_without_config_requirements() -> Result<()> {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let config = build_config(&codex_home).await;
|
|
let (client, mut commands, _events) =
|
|
codex_app_server_client::CloudClient::channel(/*capacity*/ 3);
|
|
let mut app_server = AppServerSession::new(
|
|
crate::backend_id::BackendId::new(),
|
|
AppServerClient::Cloud(client),
|
|
ThreadParamsMode::Cloud,
|
|
);
|
|
let account = GetAccountResponse {
|
|
account: Some(Account::Chatgpt {
|
|
email: Some("teammate@openai.com".to_string()),
|
|
plan_type: codex_protocol::account::PlanType::Plus,
|
|
workspace_name: None,
|
|
}),
|
|
requires_openai_auth: true,
|
|
workspace_routing: None,
|
|
};
|
|
|
|
let (bootstrap, ()) =
|
|
tokio::join!(app_server.bootstrap_with_account(&config, account), async {
|
|
for _ in 0..3 {
|
|
let command = commands.recv().await.expect("bootstrap request");
|
|
let codex_app_server_client::CloudOperation::Request(request) =
|
|
command.operation
|
|
else {
|
|
panic!("expected bootstrap request")
|
|
};
|
|
let result = match *request {
|
|
ClientRequest::ModelList { .. } => {
|
|
Ok(serde_json::to_value(ModelListResponse {
|
|
data: vec![api_model_with_upgrade_retirement_at(
|
|
/*retirement_at*/ None,
|
|
)],
|
|
next_cursor: None,
|
|
})
|
|
.expect("model response should serialize"))
|
|
}
|
|
ClientRequest::ConfigRequirementsRead { .. } => Err(JSONRPCErrorError {
|
|
code: JSONRPC_METHOD_NOT_FOUND,
|
|
message: "method not found".to_string(),
|
|
data: None,
|
|
}),
|
|
ClientRequest::CollaborationModeList { .. } => {
|
|
Ok(serde_json::json!({"data": []}))
|
|
}
|
|
request => panic!("unexpected bootstrap request: {request:?}"),
|
|
};
|
|
command.response.send(Ok(result)).unwrap();
|
|
}
|
|
});
|
|
let bootstrap = bootstrap?;
|
|
|
|
assert_ne!(config.model.as_deref(), Some("current-model"));
|
|
assert_eq!(bootstrap.default_model, "current-model");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bootstrap_empty_catalog_reports_configuration_error() -> Result<()> {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let mut config = build_config(&codex_home).await;
|
|
config.model = None;
|
|
config.model_catalog = Some(codex_protocol::openai_models::ModelsResponse::default());
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
|
|
let error = app_server
|
|
.bootstrap(&config)
|
|
.await
|
|
.err()
|
|
.expect("bootstrap requires a model");
|
|
insta::assert_snapshot!(error.to_string(), @"No models are available. Set `model` explicitly or check your model catalog configuration.");
|
|
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot {
|
|
RateLimitSnapshot {
|
|
limit_id: Some(limit_id.to_string()),
|
|
limit_name: None,
|
|
normal_model_slug: None,
|
|
primary: Some(codex_app_server_protocol::RateLimitWindow {
|
|
used_percent: 0,
|
|
window_duration_mins: Some(10_080),
|
|
resets_at: None,
|
|
}),
|
|
secondary: None,
|
|
credits: None,
|
|
individual_limit: None,
|
|
spend_control_reached: None,
|
|
plan_type: None,
|
|
rate_limit_reached_type: None,
|
|
}
|
|
}
|
|
|
|
fn api_model_with_upgrade_retirement_at(retirement_at: Option<i64>) -> ApiModel {
|
|
ApiModel {
|
|
id: "model-id".to_string(),
|
|
model: "current-model".to_string(),
|
|
upgrade: Some("replacement-model".to_string()),
|
|
upgrade_info: Some(codex_app_server_protocol::ModelUpgradeInfo {
|
|
model: "replacement-model".to_string(),
|
|
upgrade_copy: None,
|
|
model_link: None,
|
|
migration_markdown: None,
|
|
retirement_at,
|
|
}),
|
|
availability_nux: None,
|
|
display_name: "Current model".to_string(),
|
|
description: "A test model".to_string(),
|
|
model_specialty: None,
|
|
hidden: false,
|
|
supported_reasoning_efforts: Vec::new(),
|
|
default_reasoning_effort: ReasoningEffort::Medium,
|
|
input_modalities: Vec::new(),
|
|
supports_personality: false,
|
|
multi_agent_version: None,
|
|
additional_speed_tiers: Vec::new(),
|
|
service_tiers: Vec::new(),
|
|
default_service_tier: None,
|
|
available_access_programs: None,
|
|
is_default: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn model_preset_from_api_model_preserves_upgrade_retirement_at() {
|
|
let retirement_at = chrono::DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
|
|
.expect("valid RFC 3339 timestamp")
|
|
.with_timezone(&chrono::Utc);
|
|
let expected_upgrade = |retirement_at| {
|
|
Some(ModelUpgrade {
|
|
id: "replacement-model".to_string(),
|
|
migration_config_key: "current-model".to_string(),
|
|
model_link: None,
|
|
upgrade_copy: None,
|
|
migration_markdown: None,
|
|
retirement_at,
|
|
})
|
|
};
|
|
|
|
assert_eq!(
|
|
vec![
|
|
model_preset_from_api_model(api_model_with_upgrade_retirement_at(Some(
|
|
retirement_at.timestamp(),
|
|
)))
|
|
.upgrade,
|
|
model_preset_from_api_model(api_model_with_upgrade_retirement_at(
|
|
/*retirement_at*/ None,
|
|
))
|
|
.upgrade,
|
|
model_preset_from_api_model(api_model_with_upgrade_retirement_at(Some(i64::MAX)))
|
|
.upgrade,
|
|
],
|
|
vec![
|
|
expected_upgrade(Some(retirement_at)),
|
|
expected_upgrade(None),
|
|
expected_upgrade(None),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() {
|
|
let response = GetAccountRateLimitsResponse {
|
|
ordinary_usage_allowed: None,
|
|
account_id: None,
|
|
rate_limit_upsell: None,
|
|
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")),
|
|
])),
|
|
rate_limit_reset_credits: None,
|
|
};
|
|
|
|
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}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn history_pagination_compat_detects_unsupported_server_fields() {
|
|
let cases = [
|
|
(JSONRPC_INVALID_PARAMS, "unknown field `historyMode`", true),
|
|
(
|
|
JSONRPC_INVALID_REQUEST,
|
|
"thread/resume.excludeTurns requires experimentalApi capability",
|
|
true,
|
|
),
|
|
(
|
|
JSONRPC_INVALID_REQUEST,
|
|
"thread/fork.excludeTurns requires experimentalApi capability",
|
|
true,
|
|
),
|
|
(
|
|
JSONRPC_METHOD_NOT_FOUND,
|
|
"unknown method thread/turns/list",
|
|
true,
|
|
),
|
|
(JSONRPC_METHOD_NOT_FOUND, "method not found", true),
|
|
(
|
|
JSONRPC_INVALID_PARAMS,
|
|
"unknown variant \"paginated\", expected \"legacy\"",
|
|
true,
|
|
),
|
|
(
|
|
JSONRPC_INVALID_PARAMS,
|
|
"invalid enum value `paginated`",
|
|
true,
|
|
),
|
|
(
|
|
JSONRPC_INVALID_PARAMS,
|
|
"paginated thread was not found",
|
|
false,
|
|
),
|
|
(JSONRPC_INVALID_PARAMS, "invalid thread id", false),
|
|
];
|
|
|
|
for (code, message, expected) in cases {
|
|
let source = JSONRPCErrorError {
|
|
code,
|
|
data: None,
|
|
message: message.to_string(),
|
|
};
|
|
assert_eq!(
|
|
is_history_pagination_unsupported(&source),
|
|
expected,
|
|
"{message}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ephemeral_thread_start_does_not_request_paginated_history() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.ephemeral = true;
|
|
|
|
let params = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
|
|
assert_eq!(params.ephemeral, Some(true));
|
|
assert_eq!(params.history_mode, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cloud_start_resume_and_turn_leave_settings_server_owned() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.base_instructions = Some("local base instructions".to_string());
|
|
config.developer_instructions = Some("local developer instructions".to_string());
|
|
config.daybreak_enabled = true;
|
|
config.ephemeral = true;
|
|
let thread_id = ThreadId::new();
|
|
let (client, mut commands, _events) =
|
|
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
|
|
let request_handle = AppServerClient::Cloud(client).request_handle();
|
|
let mut launch_choices = StartupLaunchChoices::default();
|
|
launch_choices.model = Some("local-model".to_string());
|
|
launch_choices.effort = Some(serde_json::json!("high"));
|
|
let local_settings = LocalSettings::from(&config);
|
|
|
|
let (start, sent_params) = tokio::join!(
|
|
start_thread_with_request_handle(
|
|
request_handle,
|
|
&local_settings,
|
|
config.clone(),
|
|
ThreadParamsMode::Cloud,
|
|
Some(std::path::PathBuf::from("/local/workspace")),
|
|
ThreadToolTransport::Dynamic,
|
|
launch_choices,
|
|
),
|
|
async {
|
|
let command = commands.recv().await.expect("cloud thread start");
|
|
let codex_app_server_client::CloudOperation::Request(request) = command.operation
|
|
else {
|
|
panic!("expected cloud request")
|
|
};
|
|
let ClientRequest::ThreadStart { params, .. } = *request else {
|
|
panic!("expected thread/start request")
|
|
};
|
|
command
|
|
.response
|
|
.send(Ok(Err(JSONRPCErrorError {
|
|
code: JSONRPC_INVALID_PARAMS,
|
|
data: None,
|
|
message: "stop after recording request".to_string(),
|
|
})))
|
|
.expect("startup should still be waiting for its response");
|
|
params
|
|
},
|
|
);
|
|
let _ = start.expect_err("recording response should stop startup");
|
|
let resume = thread_resume_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Cloud,
|
|
Some(std::path::Path::new("/local/workspace")),
|
|
ResumeModelSettings::OverrideFromCurrentConfig,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
|
|
assert_eq!(sent_params, ThreadStartParams::default());
|
|
assert_eq!(
|
|
resume,
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.to_string(),
|
|
..ThreadResumeParams::default()
|
|
}
|
|
);
|
|
|
|
let (client, mut commands, _events) =
|
|
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
|
|
let mut app_server = AppServerSession::new(
|
|
crate::backend_id::BackendId::new(),
|
|
AppServerClient::Cloud(client),
|
|
ThreadParamsMode::Cloud,
|
|
);
|
|
let client_user_message_id = "local-message-id".to_string();
|
|
let input = vec![UserInput::Text {
|
|
text: "hello from the TUI".to_string(),
|
|
text_elements: Vec::new(),
|
|
}];
|
|
let output_schema = serde_json::json!({"type": "string"});
|
|
let workspace_roots = vec![test_path_buf("/local/workspace").abs()];
|
|
let (turn, sent_params) = tokio::join!(
|
|
app_server.turn_start(
|
|
thread_id,
|
|
"user",
|
|
client_user_message_id.clone(),
|
|
input.clone(),
|
|
test_path_buf("/local/workspace"),
|
|
Some(codex_app_server_protocol::AskForApproval::Never),
|
|
Some(codex_app_server_protocol::ApprovalsReviewer::User),
|
|
TurnPermissionsOverride::ActiveProfile(ActivePermissionProfile::new(
|
|
"local-permission-profile".to_string(),
|
|
)),
|
|
&workspace_roots,
|
|
"local-model".to_string(),
|
|
Some(ReasoningEffort::High),
|
|
Some(ReasoningSummary::Detailed),
|
|
Some(Some("local-service-tier".to_string())),
|
|
Some(codex_protocol::config_types::CollaborationMode {
|
|
mode: codex_protocol::config_types::ModeKind::Plan,
|
|
settings: codex_protocol::config_types::Settings {
|
|
model: "local-plan-model".to_string(),
|
|
reasoning_effort: Some(ReasoningEffort::High),
|
|
developer_instructions: Some("local plan instructions".to_string()),
|
|
},
|
|
}),
|
|
Some(output_schema.clone()),
|
|
Some(CyberAccessProgram::DaybreakBlue.into()),
|
|
),
|
|
async {
|
|
let command = commands.recv().await.expect("cloud turn start");
|
|
let codex_app_server_client::CloudOperation::Request(request) = command.operation
|
|
else {
|
|
panic!("expected cloud request")
|
|
};
|
|
let ClientRequest::TurnStart { params, .. } = *request else {
|
|
panic!("expected turn/start request")
|
|
};
|
|
command
|
|
.response
|
|
.send(Ok(Err(JSONRPCErrorError {
|
|
code: JSONRPC_INVALID_PARAMS,
|
|
data: None,
|
|
message: "stop after recording request".to_string(),
|
|
})))
|
|
.expect("turn start should still be waiting for its response");
|
|
params
|
|
},
|
|
);
|
|
let _ = turn.expect_err("recording response should stop turn start");
|
|
assert_eq!(
|
|
sent_params,
|
|
TurnStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
client_user_message_id: Some(client_user_message_id),
|
|
input,
|
|
turn_trigger: Some("user".to_string()),
|
|
output_schema: Some(output_schema),
|
|
cyber_access_program: Some(CyberAccessProgram::DaybreakBlue.into()),
|
|
..TurnStartParams::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shared_thread_start_preserves_explicit_session_overrides() -> Result<()> {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let workspace = codex_home.path().join("workspace");
|
|
std::fs::create_dir(&workspace)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
"sandbox_mode = \"workspace-write\"\n[sandbox_workspace_write]\nnetwork_access = true\n",
|
|
)?;
|
|
let server_config = build_config(&codex_home).await;
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
cwd: Some(workspace.clone()),
|
|
..ConfigOverrides::default()
|
|
})
|
|
.cli_overrides(vec![
|
|
(
|
|
"suppress_unstable_features_warning".to_string(),
|
|
toml::Value::Boolean(true),
|
|
),
|
|
(
|
|
"features.multi_agent_mode".to_string(),
|
|
toml::Value::Boolean(true),
|
|
),
|
|
(
|
|
"sandbox_workspace_write.network_access".to_string(),
|
|
toml::Value::Boolean(false),
|
|
),
|
|
(
|
|
"instructions".to_string(),
|
|
toml::Value::String("unsafe ".repeat(10_000)),
|
|
),
|
|
("model".to_string(), "gpt-5".into()),
|
|
("approval_policy".to_string(), "never".into()),
|
|
])
|
|
.build()
|
|
.await?;
|
|
|
|
let params = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
/*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
|
|
let overrides = params.config.expect("config overrides");
|
|
assert_eq!(
|
|
(
|
|
overrides.get("features").cloned(),
|
|
overrides.get("sandbox_workspace_write").cloned(),
|
|
overrides.get("instructions").cloned(),
|
|
overrides.get("suppress_unstable_features_warning").cloned(),
|
|
),
|
|
(
|
|
Some(serde_json::json!({ "multi_agent_mode": true })),
|
|
Some(serde_json::json!({ "network_access": false })),
|
|
None,
|
|
Some(serde_json::json!(true)),
|
|
)
|
|
);
|
|
for mode in [ThreadParamsMode::Embedded, ThreadParamsMode::Remote] {
|
|
let mut app_server =
|
|
crate::start_embedded_app_server_for_picker(&server_config).await?;
|
|
app_server.thread_params_mode = mode;
|
|
app_server.remote_cwd_override = Some(workspace.clone());
|
|
|
|
let started = app_server.start_thread(&config).await?;
|
|
|
|
assert_eq!(
|
|
(
|
|
started.session.permission_profile.network_sandbox_policy(),
|
|
started.session.model.as_str(),
|
|
started.session.approval_policy,
|
|
started.session.cwd.as_path(),
|
|
),
|
|
(
|
|
NetworkSandboxPolicy::Restricted,
|
|
"gpt-5",
|
|
AskForApproval::Never,
|
|
workspace.as_path(),
|
|
)
|
|
);
|
|
app_server.shutdown().await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[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.clone())
|
|
);
|
|
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, None);
|
|
assert_eq!(params.thread_source, Some(ThreadSource::User));
|
|
assert_eq!(params.dynamic_tools, None);
|
|
}
|
|
|
|
#[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(),
|
|
)
|
|
.expect("active permission profile should be supported");
|
|
|
|
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(),
|
|
)
|
|
.expect("active permission profile should be supported");
|
|
|
|
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())
|
|
.expect("preserving permissions should be supported");
|
|
|
|
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(),
|
|
)
|
|
.expect("read-only permission profile should be supported");
|
|
|
|
assert_eq!(
|
|
sandbox_policy,
|
|
Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
|
network_access: false
|
|
})
|
|
);
|
|
assert_eq!(permissions, None);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_turn_permissions_preserve_workspace_write() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
TurnPermissionsOverride::LegacySandbox(PermissionProfile::workspace_write()),
|
|
cwd.as_path(),
|
|
)
|
|
.expect("workspace-write permission profile should be supported");
|
|
|
|
assert_eq!(
|
|
sandbox_policy,
|
|
Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
|
|
writable_roots: Vec::new(),
|
|
network_access: false,
|
|
exclude_tmpdir_env_var: false,
|
|
exclude_slash_tmp: false,
|
|
})
|
|
);
|
|
assert_eq!(permissions, None);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_turn_permissions_reject_non_cwd_write_roots() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let extra_root = test_path_buf("/workspace/extra").abs();
|
|
let permission_profile = PermissionProfile::Managed {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
file_system: ManagedFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: extra_root.into(),
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
missing_path_behavior: None,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
};
|
|
|
|
let error = turn_permissions_overrides(
|
|
TurnPermissionsOverride::LegacySandbox(permission_profile),
|
|
cwd.as_path(),
|
|
)
|
|
.expect_err("non-cwd write roots must not grant cwd write access");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_turn_permissions_reject_restrictions_lost_by_projection() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let docs = test_path_buf("/workspace/project/docs").abs();
|
|
|
|
for (path, access) in [
|
|
(
|
|
FileSystemPath::Path {
|
|
path: docs.clone().into(),
|
|
},
|
|
FileSystemAccessMode::Read,
|
|
),
|
|
(
|
|
FileSystemPath::Path { path: docs.into() },
|
|
FileSystemAccessMode::Deny,
|
|
),
|
|
(
|
|
FileSystemPath::GlobPattern {
|
|
pattern: "**/*.secret".to_string(),
|
|
},
|
|
FileSystemAccessMode::Deny,
|
|
),
|
|
] {
|
|
let mut permission_profile = PermissionProfile::workspace_write();
|
|
let PermissionProfile::Managed {
|
|
file_system: ManagedFileSystemPermissions::Restricted { entries, .. },
|
|
..
|
|
} = &mut permission_profile
|
|
else {
|
|
unreachable!("workspace-write profiles use restricted managed permissions");
|
|
};
|
|
entries.push(FileSystemSandboxEntry {
|
|
path,
|
|
access,
|
|
missing_path_behavior: None,
|
|
});
|
|
|
|
let error = turn_permissions_overrides(
|
|
TurnPermissionsOverride::LegacySandbox(permission_profile),
|
|
cwd.as_path(),
|
|
)
|
|
.expect_err("legacy projection must not discard filesystem restrictions");
|
|
|
|
assert!(
|
|
error
|
|
.downcast_ref::<UnsupportedLegacyPermissionProfile>()
|
|
.is_some()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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(),
|
|
)
|
|
.expect("active permission profile should be supported");
|
|
|
|
assert_eq!(sandbox_policy, None);
|
|
assert_eq!(permissions, Some(expected_permissions));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_lifecycle_params_omit_local_paths_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 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,
|
|
ResumeModelSettings::OverrideFromCurrentConfig,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
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, None);
|
|
assert_eq!(resume.runtime_workspace_roots, None);
|
|
assert_eq!(fork.runtime_workspace_roots, None);
|
|
assert_eq!(start.model_provider, None);
|
|
assert_eq!(resume.model_provider, None);
|
|
assert_eq!(fork.model_provider, None);
|
|
assert_eq!(start.sandbox, expected_sandbox);
|
|
assert_eq!(resume.sandbox, None);
|
|
assert_eq!(resume.approval_policy, None);
|
|
assert_eq!(resume.approvals_reviewer, None);
|
|
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 remote_resume_params_keep_cwd_without_overriding_saved_permissions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let remote_cwd = if cfg!(windows) {
|
|
std::path::PathBuf::from("/srv/remote/project")
|
|
} else {
|
|
std::path::PathBuf::from(r"C:\remote\project")
|
|
};
|
|
|
|
let resume = thread_resume_params_from_config(
|
|
config,
|
|
ThreadId::new(),
|
|
ThreadParamsMode::Remote,
|
|
Some(remote_cwd.as_path()),
|
|
ResumeModelSettings::RestoreFromThread,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
|
|
assert_eq!(resume.cwd, Some(remote_cwd.to_string_lossy().to_string()));
|
|
assert_eq!(resume.runtime_workspace_roots, None);
|
|
}
|
|
|
|
#[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,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: extra_root.into(),
|
|
access: FileSystemAccessMode::Write,
|
|
missing_path_behavior: None,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
|
|
Some(codex_app_server_protocol::SandboxMode::ReadOnly)
|
|
);
|
|
assert!(!permission_profile_is_safely_represented_by_sandbox_mode(
|
|
&permission_profile,
|
|
cwd.as_path(),
|
|
));
|
|
}
|
|
|
|
#[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,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
missing_path_behavior: None,
|
|
},
|
|
],
|
|
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()),
|
|
ResumeModelSettings::OverrideFromCurrentConfig,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
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.runtime_workspace_roots, None);
|
|
assert_eq!(resume.runtime_workspace_roots, None);
|
|
assert_eq!(fork.runtime_workspace_roots, None);
|
|
assert_eq!(start.model_provider, None);
|
|
assert_eq!(resume.model_provider, None);
|
|
assert_eq!(fork.model_provider, None);
|
|
assert_eq!(start.sandbox, expected_sandbox);
|
|
assert_eq!(resume.sandbox, None);
|
|
assert_eq!(fork.sandbox, 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_config_overrides_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
|
|
.web_search_mode
|
|
.set(WebSearchMode::Disabled)
|
|
.expect("test web search mode should be allowed");
|
|
config.bypass_hook_trust = true;
|
|
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,
|
|
ResumeModelSettings::OverrideFromCurrentConfig,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
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")),
|
|
("bypass_hook_trust".to_string(), true.into()),
|
|
]);
|
|
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_overrides_forward_explicit_summary_verbosity_and_web_search() -> Result<()> {
|
|
let home = tempfile::tempdir()?;
|
|
let workspace = home.path().join("workspace");
|
|
std::fs::create_dir_all(workspace.join(".codex"))?;
|
|
std::fs::write(
|
|
home.path().join("config.toml"),
|
|
format!(
|
|
"model_reasoning_summary = \"concise\"\nmodel_verbosity = \"low\"\nweb_search = \"disabled\"\npersonality = \"none\"\n[projects.{}]\ntrust_level = \"trusted\"\n",
|
|
toml::Value::String(workspace.to_string_lossy().into_owned()),
|
|
),
|
|
)?;
|
|
let profile = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?;
|
|
std::fs::write(
|
|
&profile,
|
|
"model_reasoning_summary = \"detailed\"\nmodel_verbosity = \"high\"\nweb_search = \"cached\"\n",
|
|
)?;
|
|
for (selected_profile, project, cli, expected) in [
|
|
(false, false, false, [None, None, None]),
|
|
(
|
|
true,
|
|
false,
|
|
false,
|
|
[Some("detailed"), Some("high"), Some("cached")],
|
|
),
|
|
(
|
|
false,
|
|
false,
|
|
true,
|
|
[Some("auto"), Some("medium"), Some("live")],
|
|
),
|
|
(
|
|
true,
|
|
false,
|
|
true,
|
|
[Some("auto"), Some("medium"), Some("live")],
|
|
),
|
|
(true, true, false, [None, None, None]),
|
|
(
|
|
true,
|
|
true,
|
|
true,
|
|
[Some("auto"), Some("medium"), Some("live")],
|
|
),
|
|
] {
|
|
std::fs::write(
|
|
workspace.join(".codex/config.toml"),
|
|
if project {
|
|
"model_reasoning_summary = \"concise\"\nmodel_verbosity = \"low\"\nweb_search = \"disabled\"\n"
|
|
} else {
|
|
""
|
|
},
|
|
)?;
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(home.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
cwd: Some(workspace.clone()),
|
|
..ConfigOverrides::default()
|
|
})
|
|
.loader_overrides(codex_config::LoaderOverrides {
|
|
user_config_path: selected_profile.then(|| profile.clone()),
|
|
user_config_profile: selected_profile.then(|| "work".parse().unwrap()),
|
|
..codex_config::LoaderOverrides::without_managed_config_for_tests()
|
|
})
|
|
.cli_overrides(if cli {
|
|
vec![
|
|
("model_reasoning_summary".to_string(), "auto".into()),
|
|
("model_verbosity".to_string(), "medium".into()),
|
|
("web_search".to_string(), "live".into()),
|
|
("personality".to_string(), "none".into()),
|
|
]
|
|
} else {
|
|
Vec::new()
|
|
})
|
|
.build()
|
|
.await?;
|
|
let overrides = config_request_overrides_from_config(&config, ThreadParamsMode::Remote)
|
|
.expect("config overrides");
|
|
assert_eq!(
|
|
(
|
|
["model_reasoning_summary", "model_verbosity", "web_search"]
|
|
.map(|key| overrides.get(key).and_then(serde_json::Value::as_str)),
|
|
overrides
|
|
.get("personality")
|
|
.and_then(serde_json::Value::as_str),
|
|
),
|
|
(expected, cli.then_some("none")),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_params_can_restore_persisted_model_settings() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.model = Some("configured-model".to_string());
|
|
config.model_provider_id = "configured-provider".to_string();
|
|
config.model_reasoning_effort = Some(ReasoningEffort::Ultra);
|
|
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
|
|
|
|
let params = thread_resume_params_from_config(
|
|
config,
|
|
ThreadId::new(),
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
ResumeModelSettings::RestoreFromThread,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
|
|
assert_eq!(params.model, None);
|
|
assert_eq!(params.model_provider, None);
|
|
assert_eq!(params.config, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_resume_params_can_rejoin_without_overriding_existing_settings() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
|
|
let params = thread_resume_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
ResumeModelSettings::PreserveExistingThread,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
|
|
assert_eq!(
|
|
params,
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.to_string(),
|
|
..ThreadResumeParams::default()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn connected_thread_start_preserves_flex_without_catalog_support() -> Result<()> {
|
|
for fast_mode_enabled in [false, true] {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let server_config = build_config(&codex_home).await;
|
|
let mut config = server_config.clone();
|
|
config.model = Some("gpt-5.5".to_string());
|
|
config.service_tier = Some(ServiceTier::Flex.request_value().to_string());
|
|
config
|
|
.features
|
|
.set_enabled(Feature::FastMode, fast_mode_enabled)?;
|
|
let mut app_server =
|
|
crate::start_embedded_app_server_for_picker(&server_config).await?;
|
|
app_server.thread_params_mode = ThreadParamsMode::Remote;
|
|
let mut preset = crate::test_support::TEST_MODEL_PRESETS
|
|
.iter()
|
|
.find(|preset| preset.model == "gpt-5.5")
|
|
.expect("gpt-5.5 test preset")
|
|
.clone();
|
|
preset.service_tiers.clear();
|
|
preset.default_service_tier = None;
|
|
app_server.available_models = vec![preset];
|
|
|
|
let started = app_server.start_thread(&config).await?;
|
|
|
|
assert_eq!(
|
|
started.session.service_tier,
|
|
Some(ServiceTier::Flex.request_value().to_string())
|
|
);
|
|
app_server.shutdown().await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persisted_resume_does_not_forward_implicit_service_tier() -> Result<()> {
|
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&codex_home).await;
|
|
config.model = Some("gpt-5.5".to_string());
|
|
config.service_tier = None;
|
|
config
|
|
.features
|
|
.enable(Feature::FastMode)
|
|
.expect("enable fast mode");
|
|
let thread_id = ThreadId::from_string(
|
|
&create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some(config.model_provider_id.as_str()),
|
|
/*git_info*/ None,
|
|
)
|
|
.expect("create source rollout"),
|
|
)?;
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
let mut preset = crate::test_support::TEST_MODEL_PRESETS
|
|
.iter()
|
|
.find(|preset| preset.model == "gpt-5.5")
|
|
.expect("gpt-5.5 test preset")
|
|
.clone();
|
|
preset.service_tiers = vec![ModelServiceTier {
|
|
id: ServiceTier::Fast.request_value().to_string(),
|
|
name: "fast".to_string(),
|
|
description: "Fast tier".to_string(),
|
|
}];
|
|
preset.default_service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
|
app_server.available_models = vec![preset];
|
|
|
|
let resumed = app_server
|
|
.resume_thread(
|
|
&LocalSettings::from(&config),
|
|
config,
|
|
thread_id,
|
|
ResumeModelSettings::RestoreFromThread,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(resumed.session.service_tier, None);
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_fork_skips_parent_title_lookup_but_normal_ephemeral_fork_keeps_it() -> Result<()>
|
|
{
|
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&codex_home).await;
|
|
let source_thread_id = ThreadId::from_string(
|
|
&create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some(config.model_provider_id.as_str()),
|
|
/*git_info*/ None,
|
|
)
|
|
.expect("create source rollout"),
|
|
)?;
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
app_server
|
|
.resume_thread(
|
|
&LocalSettings::from(&config),
|
|
config.clone(),
|
|
source_thread_id,
|
|
ResumeModelSettings::RestoreFromThread,
|
|
)
|
|
.await?;
|
|
app_server
|
|
.thread_set_name(source_thread_id, "Source thread".to_string())
|
|
.await?;
|
|
|
|
let mut ephemeral_config = config;
|
|
ephemeral_config.ephemeral = true;
|
|
let normal_ephemeral_fork = app_server
|
|
.fork_thread(
|
|
&LocalSettings::from(&ephemeral_config),
|
|
ephemeral_config.clone(),
|
|
source_thread_id,
|
|
)
|
|
.await?;
|
|
let side_fork = app_server
|
|
.fork_side_thread(
|
|
&LocalSettings::from(&ephemeral_config),
|
|
ephemeral_config,
|
|
source_thread_id,
|
|
/*selected_profile*/ None,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
normal_ephemeral_fork.session.fork_parent_title.as_deref(),
|
|
Some("Source thread")
|
|
);
|
|
assert_eq!(side_fork.session.fork_parent_title, None);
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ephemeral_paginated_fork_skips_unsupported_history_hydration() -> Result<()> {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let config = build_config(&codex_home).await;
|
|
let source_thread_id = ThreadId::from_string(
|
|
&create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some(config.model_provider_id.as_str()),
|
|
/*git_info*/ None,
|
|
)
|
|
.map_err(|error| {
|
|
color_eyre::eyre::eyre!("failed to create paginated rollout: {error}")
|
|
})?,
|
|
)?;
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
let mut ephemeral_config = config;
|
|
ephemeral_config.ephemeral = true;
|
|
|
|
let fork = app_server
|
|
.fork_thread(
|
|
&LocalSettings::from(&ephemeral_config),
|
|
ephemeral_config,
|
|
source_thread_id,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(fork.session.forked_from_id, Some(source_thread_id));
|
|
assert!(fork.turns.is_empty());
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_fork_uses_one_request_for_long_paginated_history() -> Result<()> {
|
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&codex_home).await;
|
|
config.terminal_resize_reflow.max_rows =
|
|
crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(100);
|
|
let filename_ts = "2025-01-05T12-00-00";
|
|
let source_id = create_fake_paginated_rollout(
|
|
codex_home.path(),
|
|
filename_ts,
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some(config.model_provider_id.as_str()),
|
|
/*git_info*/ None,
|
|
)
|
|
.expect("create long paginated source rollout");
|
|
let source_path =
|
|
app_test_support::rollout_path(codex_home.path(), filename_ts, source_id.as_str());
|
|
let mut contents = std::fs::read_to_string(&source_path)?;
|
|
let rollout_line = |ordinal: usize, payload: serde_json::Value| {
|
|
serde_json::json!({
|
|
"timestamp": "2025-01-05T12:00:00Z",
|
|
"type": "event_msg",
|
|
"payload": payload,
|
|
"ordinal": ordinal,
|
|
})
|
|
};
|
|
let started = rollout_line(
|
|
/*ordinal*/ 3,
|
|
serde_json::json!({
|
|
"type": "task_started",
|
|
"turn_id": "long-history-turn",
|
|
"model_context_window": null,
|
|
}),
|
|
);
|
|
contents.push_str(&format!("{started}\n"));
|
|
for index in 0..256 {
|
|
let item = rollout_line(
|
|
index + 4,
|
|
serde_json::json!({
|
|
"type": "item_completed",
|
|
"thread_id": source_id,
|
|
"turn_id": "long-history-turn",
|
|
"item": {
|
|
"type": "UserMessage",
|
|
"id": format!("long-history-user-{index}"),
|
|
"content": [{
|
|
"type": "text",
|
|
"text": format!("long history message {index}"),
|
|
}],
|
|
},
|
|
}),
|
|
);
|
|
contents.push_str(&format!("{item}\n"));
|
|
}
|
|
std::fs::write(source_path, contents)?;
|
|
|
|
let source_thread_id = ThreadId::from_string(source_id.as_str())?;
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
let resumed = app_server
|
|
.resume_thread(
|
|
&LocalSettings::from(&config),
|
|
config.clone(),
|
|
source_thread_id,
|
|
ResumeModelSettings::RestoreFromThread,
|
|
)
|
|
.await?;
|
|
let loaded_items: usize = resumed.turns.iter().map(|turn| turn.items.len()).sum();
|
|
assert!(loaded_items <= HISTORY_ITEM_PAGE_LIMIT as usize);
|
|
assert!(app_server.has_older_history(source_thread_id));
|
|
|
|
let mut side_config = config;
|
|
side_config.ephemeral = true;
|
|
let next_request_id = app_server.next_request_id;
|
|
let side = app_server
|
|
.fork_side_thread(
|
|
&LocalSettings::from(&side_config),
|
|
side_config,
|
|
source_thread_id,
|
|
/*selected_profile*/ None,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(app_server.next_request_id, next_request_id + 1);
|
|
assert_eq!(side.session.forked_from_id, Some(source_thread_id));
|
|
assert_eq!(side.turns, Vec::<Turn>::new());
|
|
assert!(app_server.has_older_history(source_thread_id));
|
|
assert!(
|
|
!app_server
|
|
.history_pagination
|
|
.contains_key(&side.session.thread_id)
|
|
);
|
|
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[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.base_instructions_provenance = Some(BaseInstructionsProvenance::Custom);
|
|
config.developer_instructions = Some("Developer override.".to_string());
|
|
let thread_id = ThreadId::new();
|
|
|
|
let params = thread_fork_params_from_config(
|
|
config.clone(),
|
|
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.")
|
|
);
|
|
|
|
config.base_instructions_provenance = Some(BaseInstructionsProvenance::Model {
|
|
model: "gpt-5.2".to_string(),
|
|
});
|
|
let params = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Remote,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
|
|
assert_eq!(params.base_instructions, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cloud_fork_is_rejected_before_sending_local_settings() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let (client, mut commands, _events) =
|
|
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
|
|
let mut app_server = AppServerSession::new(
|
|
crate::backend_id::BackendId::new(),
|
|
AppServerClient::Cloud(client),
|
|
ThreadParamsMode::Cloud,
|
|
);
|
|
|
|
let error = app_server
|
|
.fork_thread(&LocalSettings::from(&config), config, ThreadId::new())
|
|
.await
|
|
.expect_err("cloud fork should be unavailable");
|
|
|
|
assert_eq!(
|
|
error.to_string(),
|
|
"Cloud conversations cannot be forked in the TUI yet."
|
|
);
|
|
assert!(commands.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn side_fork_excludes_turns_without_clearing_regular_ephemeral_fork() -> Result<()> {
|
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&codex_home).await;
|
|
config.ephemeral = true;
|
|
let thread_id = ThreadId::from_string(
|
|
&create_fake_rollout(
|
|
codex_home.path(),
|
|
"2025-01-05T12-00-00",
|
|
"2025-01-05T12:00:00Z",
|
|
"Saved user message",
|
|
Some(config.model_provider_id.as_str()),
|
|
/*git_info*/ None,
|
|
)
|
|
.expect("create rollout"),
|
|
)?;
|
|
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
|
|
|
|
let regular = app_server
|
|
.fork_thread(&LocalSettings::from(&config), config.clone(), thread_id)
|
|
.await?;
|
|
let side = app_server
|
|
.fork_side_thread(
|
|
&LocalSettings::from(&config),
|
|
config,
|
|
thread_id,
|
|
/*selected_profile*/ None,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(regular.turns.len(), 1);
|
|
assert!(matches!(
|
|
regular.turns[0].items.as_slice(),
|
|
[codex_app_server_protocol::ThreadItem { payload: codex_app_server_protocol::ThreadItemPayload::UserMessage { content, .. }, .. }]
|
|
if content == &[UserInput::Text {
|
|
text: "Saved user message".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
));
|
|
assert_eq!(side.turns, Vec::<Turn>::new());
|
|
app_server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_visualization_instructions_are_gated_for_all_tui_thread_flows() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.developer_instructions = Some("Developer override.".to_string());
|
|
let thread_id = ThreadId::new();
|
|
|
|
let control_start = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
let control_resume = thread_resume_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
ResumeModelSettings::OverrideFromCurrentConfig,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
let control_fork = thread_fork_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
|
|
assert_eq!(control_start.developer_instructions, None);
|
|
assert_eq!(control_resume.developer_instructions, None);
|
|
assert_eq!(
|
|
control_fork.developer_instructions.as_deref(),
|
|
Some("Developer override.")
|
|
);
|
|
|
|
let _ = config
|
|
.features
|
|
.enable(Feature::TerminalVisualizationInstructions);
|
|
let treatment_start = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
let treatment_resume = thread_resume_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
ResumeModelSettings::OverrideFromCurrentConfig,
|
|
crate::resume_permissions::ResumePermissions::CURRENT_CONFIG,
|
|
);
|
|
let treatment_fork = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
let expected = format!(
|
|
"Developer override.\n\n{}",
|
|
crate::terminal_visualization_instructions::TERMINAL_VISUALIZATION_INSTRUCTIONS
|
|
);
|
|
|
|
assert_eq!(
|
|
treatment_start.developer_instructions.as_deref(),
|
|
Some(expected.as_str())
|
|
);
|
|
assert_eq!(
|
|
treatment_resume.developer_instructions.as_deref(),
|
|
Some(expected.as_str())
|
|
);
|
|
assert_eq!(
|
|
treatment_fork.developer_instructions.as_deref(),
|
|
Some(expected.as_str())
|
|
);
|
|
}
|
|
|
|
#[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 {
|
|
disabled_plugin_ids: Vec::new(),
|
|
thread: codex_app_server_protocol::Thread {
|
|
originator: None,
|
|
environments: None,
|
|
id: thread_id.to_string(),
|
|
extra: None,
|
|
session_id: ThreadId::new().to_string(),
|
|
forked_from_id: Some(forked_from_id.to_string()),
|
|
parent_thread_id: None,
|
|
preview: "hello".to_string(),
|
|
ephemeral: false,
|
|
section: None,
|
|
section_entered_at: None,
|
|
project_id: None,
|
|
daybreak_enabled: None,
|
|
history_mode: Default::default(),
|
|
model_provider: "openai".to_string(),
|
|
model: None,
|
|
reasoning_effort: None,
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
recency_at: Some(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,
|
|
can_accept_direct_input: None,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: vec![Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![
|
|
ThreadItem::untimed(
|
|
codex_app_server_protocol::ThreadItemPayload::UserMessage {
|
|
id: "user-1".to_string(),
|
|
client_id: None,
|
|
content: vec![codex_app_server_protocol::UserInput::Text {
|
|
text: "hello from history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
},
|
|
),
|
|
ThreadItem::untimed(
|
|
codex_app_server_protocol::ThreadItemPayload::AgentMessage {
|
|
id: "assistant-1".to_string(),
|
|
text: "assistant reply".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
},
|
|
),
|
|
],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}],
|
|
},
|
|
model: "gpt-5.5".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![LegacyAppPathString::from_abs_path(
|
|
&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,
|
|
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
|
|
mode: codex_protocol::config_types::ModeKind::Plan,
|
|
settings: codex_protocol::config_types::Settings {
|
|
model: "gpt-5.5".to_string(),
|
|
reasoning_effort: None,
|
|
developer_instructions: Some("Keep planning".to_string()),
|
|
},
|
|
}),
|
|
multi_agent_mode: Default::default(),
|
|
initial_turns_page: None,
|
|
turns_backwards_cursor: None,
|
|
items_backwards_cursor: None,
|
|
};
|
|
|
|
let started = started_thread_from_resume_response(
|
|
response.clone(),
|
|
&LocalSettings::from(&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_source_path_uris()
|
|
);
|
|
assert_eq!(started.session.permission_profile, read_only_profile);
|
|
assert_eq!(started.turns.len(), 1);
|
|
assert_eq!(started.turns[0], response.thread.turns[0]);
|
|
assert!(!started.blocks_direct_input);
|
|
|
|
// The first prompt after resume must preserve the server's restored mode.
|
|
let (mut chat, _sender, _events, mut commands) =
|
|
crate::chatwidget::tests::helpers::make_chatwidget_manual_with_sender().await;
|
|
chat.handle_thread_session(started.session);
|
|
insta::assert_snapshot!(
|
|
"resumed_plan_mode",
|
|
crate::chatwidget::tests::helpers::normalize_snapshot_paths(
|
|
crate::chatwidget::tests::helpers::render_bottom_popup(&chat, /*width*/ 40),
|
|
)
|
|
);
|
|
chat.handle_paste("Continue planning".to_string());
|
|
chat.handle_key_event(crossterm::event::KeyCode::Enter.into());
|
|
let submitted_mode = std::iter::from_fn(|| commands.try_recv().ok()).find_map(|command| {
|
|
if let crate::app_command::AppCommand::UserTurn {
|
|
collaboration_mode, ..
|
|
} = command
|
|
{
|
|
Some(collaboration_mode)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
assert_eq!(submitted_mode, Some(response.collaboration_mode.clone()));
|
|
|
|
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(),
|
|
&LocalSettings::from(&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,
|
|
&LocalSettings::from(&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(),
|
|
crate::app::WindowsSandboxHost::Local,
|
|
/*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,
|
|
&LocalSettings::from(&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(),
|
|
crate::app::WindowsSandboxHost::Local,
|
|
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,
|
|
&LocalSettings::from(&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"
|
|
));
|
|
|
|
let business_prolite = status_account_display_from_auth_mode(
|
|
Some(AuthMode::Chatgpt),
|
|
Some(codex_protocol::account::PlanType::SelfServeBusinessProLite),
|
|
);
|
|
assert!(matches!(
|
|
business_prolite,
|
|
Some(StatusAccountDisplay::ChatGpt {
|
|
email: None,
|
|
plan: Some(ref plan),
|
|
..
|
|
}) if plan == "Business Premium"
|
|
));
|
|
}
|
|
}
|