mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
## What changed - Register an additional Cloud connection on supported macOS and Linux launches when `TuiMultiBackend` is enabled, using the selected ChatGPT account and local network policy. - Preserve backend settings and identify Cloud in connection controls and status. - Let Cloud supply the model when a thread opens, skip local skills requests and stale results, and allow resume without a local working-directory override. - Preserve Git metadata in Cloud thread snapshots and continue history hydration past empty filtered pages within the scan budget. - Report Cloud connection failures and require reopening Codex to reconnect. ## Testing Add regression coverage for Cloud model bootstrap, skipped skills requests, resume directory handling, history pagination through filtered pages, and Git metadata preservation. GitOrigin-RevId: 6717f0f5ad7708c5e9b5796d94711dd1eecc49bb
5188 lines
196 KiB
Rust
5188 lines
196 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 account;
|
||
pub(crate) use account::SessionAccount;
|
||
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 read_state;
|
||
mod realtime;
|
||
mod rollout_history;
|
||
pub(crate) use read_state::unread_revision;
|
||
mod startup_launch;
|
||
mod thread_list;
|
||
pub(crate) mod user_message_submission;
|
||
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::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::ThreadBackgroundTerminal;
|
||
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
|
||
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
|
||
use codex_app_server_protocol::ThreadBackgroundTerminalsListParams;
|
||
use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse;
|
||
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::Result;
|
||
use color_eyre::eyre::WrapErr;
|
||
use futures::FutureExt;
|
||
use futures::future::Either;
|
||
use futures::future::RemoteHandle;
|
||
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 tokio::sync::Semaphore;
|
||
use tokio_util::sync::CancellationToken;
|
||
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,
|
||
/// Cloud supplies its model when it starts or resumes a thread.
|
||
pub(crate) default_model: Option<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,
|
||
/// Owns events and the connection lifetime for live sessions. Request-only views share the
|
||
/// request handle below and have no connection owner.
|
||
connection_owner: Option<AppServerClient>,
|
||
request_handle: AppServerRequestHandle,
|
||
request_id_namespace: Option<Uuid>,
|
||
next_request_id: i64,
|
||
automatic_name_writes: HashMap<ThreadId, Arc<Semaphore>>,
|
||
/// The newest docked replay refresh on this connection.
|
||
pub(crate) pending_side_skills_refresh: Option<Uuid>,
|
||
history_pagination: HashMap<ThreadId, history::ThreadHistoryPagination>,
|
||
pub(crate) focused_conversation: Option<ThreadId>,
|
||
pub(crate) attempted_revision: Option<String>,
|
||
pub(crate) frozen_receipt: Option<(ThreadId, String)>,
|
||
pub(crate) unread_revisions: HashMap<ThreadId, String>,
|
||
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>,
|
||
prefetched_model_list: Option<RemoteHandle<Result<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>>,
|
||
}
|
||
|
||
/// An automatic save holds the gate only while writing, so a manual rename can skip a slow read.
|
||
pub(crate) struct AutomaticThreadName {
|
||
session: AppServerSession,
|
||
thread_id: ThreadId,
|
||
gate: Arc<Semaphore>,
|
||
}
|
||
|
||
impl AutomaticThreadName {
|
||
pub(crate) async fn save(mut self, title: String, cancellation: &CancellationToken) {
|
||
let read = self
|
||
.session
|
||
.thread_read(self.thread_id, /*include_turns*/ false);
|
||
let Some(Ok(thread)) = cancellation.run_until_cancelled(read).await else {
|
||
return;
|
||
};
|
||
if thread.name.is_some() {
|
||
return;
|
||
}
|
||
let Ok(_permit) = self.gate.acquire().await else {
|
||
return;
|
||
};
|
||
if !cancellation.is_cancelled()
|
||
&& let Err(error) = self.session.thread_set_name(self.thread_id, title).await
|
||
{
|
||
tracing::debug!(%error, "failed to apply generated thread title");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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)]
|
||
pub(crate) struct SidebarFork {
|
||
pub(crate) thread: AppServerStartedThread,
|
||
pub(crate) boundary: Result<(), String>,
|
||
pub(crate) inherited_profile: bool,
|
||
legacy: bool,
|
||
}
|
||
|
||
#[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,
|
||
}
|
||
|
||
/// Recognize typed server errors without depending on wording or an older server's behavior.
|
||
pub(crate) fn model_provider_error(
|
||
error: &color_eyre::eyre::Report,
|
||
) -> Option<codex_app_server_protocol::ModelProviderError> {
|
||
error.chain().find_map(|cause| {
|
||
let codex_app_server_client::TypedRequestError::Server { source, .. } =
|
||
cause.downcast_ref::<codex_app_server_client::TypedRequestError>()?
|
||
else {
|
||
return None;
|
||
};
|
||
serde_json::from_value(source.data.clone()?).ok()
|
||
})
|
||
}
|
||
|
||
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.connection_owner
|
||
.as_ref()
|
||
.and_then(AppServerClient::platform_family)
|
||
}
|
||
|
||
pub(crate) fn app_server_platform_os(&self) -> Option<&str> {
|
||
self.connection_owner
|
||
.as_ref()
|
||
.and_then(AppServerClient::platform_os)
|
||
}
|
||
|
||
pub(crate) fn new(
|
||
backend_id: crate::backend_id::BackendId,
|
||
client: AppServerClient,
|
||
thread_params_mode: ThreadParamsMode,
|
||
) -> Self {
|
||
let request_handle = client.request_handle();
|
||
Self::from_parts(backend_id, Some(client), request_handle, thread_params_mode)
|
||
}
|
||
|
||
fn from_parts(
|
||
backend_id: crate::backend_id::BackendId,
|
||
connection_owner: Option<AppServerClient>,
|
||
request_handle: AppServerRequestHandle,
|
||
thread_params_mode: ThreadParamsMode,
|
||
) -> Self {
|
||
Self {
|
||
backend_id,
|
||
connection_owner,
|
||
request_handle,
|
||
request_id_namespace: None,
|
||
next_request_id: 1,
|
||
automatic_name_writes: HashMap::new(),
|
||
pending_side_skills_refresh: None,
|
||
history_pagination: HashMap::new(),
|
||
focused_conversation: None,
|
||
attempted_revision: None,
|
||
frozen_receipt: None,
|
||
unread_revisions: 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(),
|
||
prefetched_model_list: None,
|
||
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
|
||
}
|
||
|
||
/// Creates a request-only view without transferring the live connection's events.
|
||
pub(crate) fn request_view(&self) -> Self {
|
||
let mut view = Self::from_parts(
|
||
self.backend_id,
|
||
/*connection_owner*/ None,
|
||
self.request_handle(),
|
||
self.thread_params_mode,
|
||
);
|
||
view.request_id_namespace = Some(Uuid::new_v4());
|
||
view.remote_cwd_override = self.remote_cwd_override.clone();
|
||
view.model_provider_override = self.model_provider_override.clone();
|
||
view.history_support = self.history_support;
|
||
view.thread_settings_update_supported = self.thread_settings_update_supported;
|
||
view.default_model = self.default_model.clone();
|
||
view.available_models = self.available_models.clone();
|
||
view
|
||
}
|
||
|
||
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
|
||
};
|
||
if config
|
||
.mcp_servers
|
||
.get()
|
||
.contains_key(crate::dynamic_tools::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(crate::dynamic_tools::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,
|
||
worktrees,
|
||
)
|
||
.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 {
|
||
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.connection_owner, Some(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.connection_owner
|
||
.as_ref()
|
||
.and_then(|client| client.codex_home(local_codex_home))
|
||
}
|
||
|
||
pub(crate) fn server_version(&self) -> Option<&str> {
|
||
let Some(AppServerClient::Remote(client)) = &self.connection_owner else {
|
||
return None;
|
||
};
|
||
client.server_version()
|
||
}
|
||
|
||
pub(crate) fn server_codex_home(&self) -> Option<&str> {
|
||
let Some(AppServerClient::Remote(client)) = &self.connection_owner else {
|
||
return None;
|
||
};
|
||
client.codex_home()
|
||
}
|
||
|
||
pub(crate) fn prefetch_model_list(&mut self) {
|
||
let (request, models) = self.request_model_list().remote_handle();
|
||
tokio::spawn(request);
|
||
self.prefetched_model_list = Some(models);
|
||
}
|
||
|
||
fn request_model_list(&mut self) -> impl Future<Output = Result<Vec<ModelPreset>>> + use<> {
|
||
let mode = self.thread_params_mode;
|
||
let handle = self.request_handle();
|
||
let request_id = self.next_request_id();
|
||
async move {
|
||
models::list(mode, handle, request_id).await.map_err(|err| {
|
||
bootstrap_request_error("model/list failed during TUI bootstrap", err)
|
||
})
|
||
}
|
||
}
|
||
|
||
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();
|
||
let models = match self.prefetched_model_list.take() {
|
||
Some(models) => Either::Left(models),
|
||
None => Either::Right(self.request_model_list()),
|
||
};
|
||
let requirements_request_id = self.next_request_id();
|
||
let (mut available_models, requirements, collaboration_modes) = tokio::try_join!(
|
||
models,
|
||
async {
|
||
match self
|
||
.request_handle
|
||
.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) },
|
||
)?;
|
||
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()),
|
||
});
|
||
if default_model.is_none() && self.thread_params_mode != ThreadParamsMode::Cloud {
|
||
color_eyre::eyre::bail!(
|
||
"No models are available. Set `model` explicitly or check your model catalog configuration."
|
||
);
|
||
}
|
||
self.default_model.clone_from(&default_model);
|
||
self.available_models = available_models.clone();
|
||
|
||
let account = SessionAccount::from(account);
|
||
Ok(AppServerBootstrap {
|
||
duration: started_at.elapsed(),
|
||
account_email: account.email().map(str::to_owned),
|
||
auth_mode: account.auth_mode(),
|
||
feedback_audience: account.feedback_audience(),
|
||
has_chatgpt_account: account.has_chatgpt_account(),
|
||
status_account_display: account.display,
|
||
plan_type: account.plan,
|
||
requires_openai_auth: account.requires_openai_auth,
|
||
default_model,
|
||
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.request_handle
|
||
.request_typed(ClientRequest::GetAccount {
|
||
request_id: account_request_id,
|
||
params: GetAccountParams {
|
||
refresh_token: false,
|
||
},
|
||
})
|
||
.await
|
||
.map_err(|err| bootstrap_request_error("account/read failed", err))
|
||
}
|
||
|
||
pub(crate) async fn next_event(&mut self) -> Option<AppServerSessionEvent> {
|
||
let event = self.connection_owner.as_mut()?.next_event().await?;
|
||
if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = &event
|
||
&& let codex_app_server_protocol::ServerNotification::ThreadReadStateChanged(change) =
|
||
notification.as_ref()
|
||
&& let Ok(id) = ThreadId::from_string(&change.thread_id)
|
||
{
|
||
self.remember_read_state(id, Some(change.read_state.clone()));
|
||
}
|
||
Some(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_thread_for_directory_change(
|
||
&mut self,
|
||
local_settings: &LocalSettings,
|
||
config: Config,
|
||
thread_id: ThreadId,
|
||
selected_profile: Option<&PermissionProfileSelection>,
|
||
) -> Result<AppServerStartedThread> {
|
||
let permission_mode = match self.thread_params_mode() {
|
||
ThreadParamsMode::Embedded => ForkPermissionMode::OverrideFromCurrentConfig,
|
||
ThreadParamsMode::Remote | ThreadParamsMode::Cloud => ForkPermissionMode::InheritSaved,
|
||
};
|
||
self.fork_thread_at_with_presentation(
|
||
local_settings,
|
||
config,
|
||
thread_id,
|
||
/*last_turn_id*/ None,
|
||
/*before_turn_id*/ None,
|
||
ForkGoalContinuation::DeferUntilNextTurn,
|
||
ForkPresentation::Regular,
|
||
selected_profile,
|
||
permission_mode,
|
||
ForkConfigSource::Session,
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Fork and inject the side boundary on a request-only view. The live session
|
||
/// keeps receiving notifications and drawing frames while these RPCs run.
|
||
pub(crate) fn sidebar_fork(
|
||
&self,
|
||
settings: LocalSettings,
|
||
config: Config,
|
||
parent: ThreadId,
|
||
profile: Option<PermissionProfileSelection>,
|
||
boundary: ResponseItem,
|
||
) -> impl Future<Output = Result<SidebarFork>> + Send + use<> {
|
||
let mut view = self
|
||
.request_view()
|
||
.with_thread_tool_transport(self.thread_tool_transport());
|
||
if self.task_tools_available(parent) {
|
||
view.remember_task_tool_thread(parent);
|
||
}
|
||
async move {
|
||
let thread = view
|
||
.fork_side_thread(&settings, config, parent, profile.as_ref())
|
||
.await?;
|
||
let boundary = view
|
||
.thread_inject_items(thread.session.thread_id, vec![boundary])
|
||
.await
|
||
.map(|_| ())
|
||
.map_err(|err| err.to_string());
|
||
Ok(SidebarFork {
|
||
thread,
|
||
boundary,
|
||
inherited_profile: profile.is_some(),
|
||
legacy: view.history_support == ThreadHistorySupport::LegacyOnly,
|
||
})
|
||
}
|
||
}
|
||
|
||
pub(crate) fn observe_sidebar_fork(&mut self, fork: &SidebarFork) {
|
||
if fork.legacy {
|
||
self.history_support = ThreadHistorySupport::LegacyOnly;
|
||
}
|
||
if fork.thread.task_tools_available {
|
||
self.remember_task_tool_thread(fork.thread.session.thread_id);
|
||
}
|
||
}
|
||
|
||
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 permission_mode == ForkPermissionMode::InheritSaved && selected_profile.is_none() {
|
||
params.approvals_reviewer = None;
|
||
if let Some(overrides) = params.config.as_mut() {
|
||
overrides.remove("approvals_reviewer");
|
||
}
|
||
}
|
||
}
|
||
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
|
||
.request_handle
|
||
.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.request_handle
|
||
.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.request_handle
|
||
.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
|
||
.request_handle
|
||
.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) => {
|
||
// Freeze the metadata revision observed before history hydration.
|
||
if !include_turns {
|
||
self.remember_read_state(thread_id, response.read_state);
|
||
}
|
||
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.request_handle
|
||
.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
|
||
.request_handle
|
||
.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
|
||
.request_handle
|
||
.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
|
||
.request_handle
|
||
.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.request_handle
|
||
.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
|
||
.request_handle
|
||
.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.request_handle
|
||
.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.request_handle
|
||
.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
|
||
.request_handle
|
||
.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.request_handle
|
||
.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) fn automatic_thread_name_writer(
|
||
&mut self,
|
||
thread_id: ThreadId,
|
||
) -> AutomaticThreadName {
|
||
self.automatic_name_writes
|
||
.retain(|_, gate| Arc::strong_count(gate) > 1);
|
||
let session = self.request_view();
|
||
let gate = self
|
||
.automatic_name_writes
|
||
.entry(thread_id)
|
||
.or_insert_with(|| Arc::new(Semaphore::new(/*permits*/ 1)));
|
||
AutomaticThreadName {
|
||
session,
|
||
thread_id,
|
||
gate: Arc::clone(gate),
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn thread_set_name(
|
||
&mut self,
|
||
thread_id: ThreadId,
|
||
name: String,
|
||
) -> Result<()> {
|
||
let gate = self.automatic_name_writes.get(&thread_id).cloned();
|
||
let _permit = match &gate {
|
||
Some(gate) => Some(gate.acquire().await?),
|
||
None => None,
|
||
};
|
||
let request_id = self.next_request_id();
|
||
let _: ThreadSetNameResponse = self
|
||
.request_handle
|
||
.request_typed(ClientRequest::ThreadSetName {
|
||
request_id,
|
||
params: ThreadSetNameParams {
|
||
thread_id: thread_id.to_string(),
|
||
name,
|
||
},
|
||
})
|
||
.await
|
||
.wrap_err("thread/name/set failed in TUI")?;
|
||
if let Some(gate) = gate.as_ref() {
|
||
gate.close();
|
||
self.automatic_name_writes.remove(&thread_id);
|
||
}
|
||
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
|
||
.request_handle
|
||
.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
|
||
.request_handle
|
||
.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.request_handle
|
||
.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.request_handle
|
||
.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.request_handle
|
||
.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
|
||
.request_handle
|
||
.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
|
||
.request_handle
|
||
.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
|
||
.request_handle
|
||
.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
|
||
.request_handle
|
||
.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
|
||
.request_handle
|
||
.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 list_background_terminals(
|
||
&mut self,
|
||
thread_id: ThreadId,
|
||
) -> Result<Vec<ThreadBackgroundTerminal>> {
|
||
let mut terminals = Vec::new();
|
||
let mut cursor = None;
|
||
loop {
|
||
let request_id = self.next_request_id();
|
||
let page: ThreadBackgroundTerminalsListResponse = self
|
||
.request_handle
|
||
.request_typed(ClientRequest::ThreadBackgroundTerminalsList {
|
||
request_id,
|
||
params: ThreadBackgroundTerminalsListParams {
|
||
thread_id: thread_id.to_string(),
|
||
cursor,
|
||
limit: None,
|
||
},
|
||
})
|
||
.await?;
|
||
terminals.extend(page.data);
|
||
cursor = page.next_cursor;
|
||
if cursor.is_none() {
|
||
return Ok(terminals);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn thread_background_terminals_clean(
|
||
&mut self,
|
||
thread_id: ThreadId,
|
||
) -> Result<()> {
|
||
let request_id = self.next_request_id();
|
||
let _: ThreadBackgroundTerminalsCleanResponse = self
|
||
.request_handle
|
||
.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.request_handle
|
||
.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> {
|
||
self.pending_side_skills_refresh = None;
|
||
let request_id = self.next_request_id();
|
||
self.request_handle
|
||
.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
|
||
.request_handle
|
||
.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<()> {
|
||
let client = self.connection_owner.as_ref().ok_or_else(|| {
|
||
std::io::Error::other("request-only session cannot resolve server requests")
|
||
})?;
|
||
client.resolve_server_request(request_id, result).await
|
||
}
|
||
|
||
pub(crate) async fn shutdown(self) -> std::io::Result<()> {
|
||
match self.connection_owner {
|
||
Some(client) => client.shutdown().await,
|
||
None => Ok(()),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn request_handle(&self) -> AppServerRequestHandle {
|
||
self.request_handle.clone()
|
||
}
|
||
|
||
pub(crate) fn next_request_id(&mut self) -> RequestId {
|
||
let request_id = self.next_request_id;
|
||
self.next_request_id += 1;
|
||
self.request_id_namespace.map_or_else(
|
||
|| RequestId::Integer(request_id),
|
||
|namespace| RequestId::String(format!("picker-{namespace}-{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::ThreadItemsListCursor;
|
||
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 serde_json::json;
|
||
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")
|
||
}
|
||
|
||
#[test]
|
||
fn picker_views_use_distinct_request_id_namespaces() {
|
||
let (client, _commands, _events) =
|
||
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
|
||
let mut session = AppServerSession::new(
|
||
crate::backend_id::BackendId::new(),
|
||
AppServerClient::Cloud(client),
|
||
ThreadParamsMode::Cloud,
|
||
);
|
||
let mut first = session.request_view();
|
||
let mut second = session.request_view();
|
||
|
||
let first_id = first.next_request_id();
|
||
assert_ne!(session.next_request_id(), first_id);
|
||
assert_ne!(first_id, second.next_request_id());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn picker_view_shutdown_preserves_live_event_stream() {
|
||
let (client, _commands, events) =
|
||
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
|
||
let backend_id = crate::backend_id::BackendId::new();
|
||
let mut session = AppServerSession::new(
|
||
backend_id,
|
||
AppServerClient::Cloud(client),
|
||
ThreadParamsMode::Cloud,
|
||
);
|
||
|
||
session.request_view().shutdown().await.unwrap();
|
||
events
|
||
.send(codex_app_server_client::AppServerEvent::Disconnected {
|
||
message: "still live".to_string(),
|
||
})
|
||
.unwrap();
|
||
|
||
let event = session.next_event().await.unwrap();
|
||
assert_eq!(event.backend_id, backend_id);
|
||
assert!(matches!(
|
||
event.event,
|
||
codex_app_server_client::AppServerEvent::Disconnected { .. }
|
||
));
|
||
}
|
||
|
||
#[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_waits_for_the_thread_to_supply_its_model() -> Result<()> {
|
||
let codex_home = tempfile::tempdir()?;
|
||
let mut config = build_config(&codex_home).await;
|
||
config.model = Some("local-only-model".to_string());
|
||
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..2 {
|
||
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::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_eq!(bootstrap.default_model, None);
|
||
assert!(bootstrap.available_models.is_empty());
|
||
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 thread = response.thread.clone();
|
||
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());
|
||
|
||
// Cloud can filter every item in its newest page while older prompts remain.
|
||
thread.history_mode = ThreadHistoryMode::Paginated;
|
||
let mut turn = thread.turns.pop().unwrap();
|
||
let prompt = turn.items.remove(0);
|
||
turn.items.clear();
|
||
turn.items_view = codex_app_server_protocol::TurnItemsView::NotLoaded;
|
||
let (client, mut commands, _events) =
|
||
codex_app_server_client::CloudClient::channel(/*capacity*/ 1);
|
||
let mut server = AppServerSession::new(
|
||
crate::backend_id::BackendId::new(),
|
||
AppServerClient::Cloud(client),
|
||
ThreadParamsMode::Cloud,
|
||
);
|
||
let worker = tokio::spawn(async move {
|
||
while let Some(command) = commands.recv().await {
|
||
let codex_app_server_client::CloudOperation::Request(request) = command.operation
|
||
else {
|
||
panic!("expected history request");
|
||
};
|
||
let reply = match *request {
|
||
ClientRequest::ThreadTurnsList { .. } => json!({"data": [turn]}),
|
||
ClientRequest::ThreadItemsList { params, .. } if params.cursor.is_none() => {
|
||
json!({"data": [], "nextCursor": "older"})
|
||
}
|
||
ClientRequest::ThreadItemsList { params, .. }
|
||
if params.cursor == Some(ThreadItemsListCursor::Opaque("older".into())) =>
|
||
{
|
||
assert_eq!(params.limit, Some(HISTORY_ITEM_PAGE_LIMIT));
|
||
json!({"data": [{"turnId": turn.id, "item": prompt}], "nextCursor": "oldest"})
|
||
}
|
||
request => panic!("unexpected history request: {request:?}"),
|
||
};
|
||
command.response.send(Ok(Ok(reply))).unwrap();
|
||
}
|
||
});
|
||
let mut settings = LocalSettings::from(&config);
|
||
settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
settings.tui.terminal_resize_reflow_max_rows = Some(1);
|
||
server
|
||
.hydrate_initial_thread_history(
|
||
&mut thread,
|
||
/*turn_cursor*/ None,
|
||
/*item_cursor*/ None,
|
||
Some(&config),
|
||
Some(&settings),
|
||
HistoryHydrationScope::Initial,
|
||
)
|
||
.await
|
||
.expect("Cloud history should load past a hidden page");
|
||
let cells = crate::thread_transcript::thread_to_transcript_cells(
|
||
thread,
|
||
crate::thread_transcript::RawReasoningVisibility::Hidden,
|
||
/*config*/ None,
|
||
);
|
||
let lines = cells[0].display_lines(/*width*/ 80);
|
||
insta::assert_snapshot!(lines.get(1).map(ToString::to_string).unwrap_or_default(), @"› hello from history");
|
||
let next = server.begin_older_history_page(thread_id);
|
||
assert_eq!(next.as_deref(), Some("oldest"));
|
||
drop(server);
|
||
worker.await.unwrap();
|
||
}
|
||
|
||
#[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"
|
||
));
|
||
}
|
||
}
|