mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
## What changed Remove automatic next-message generation after successful turns, suggestion rendering in the composer, and the associated Tab acceptance and Escape dismissal handling. Remove the `tui.prompt_suggestions` configuration option and its schema entry, along with suggestion-specific tests and snapshots. GitOrigin-RevId: 63bf6f2ca85da6ece502c86e2a805d000bf34210
4256 lines
156 KiB
Rust
4256 lines
156 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 external_agent_config;
|
|
pub(crate) mod fs;
|
|
mod history;
|
|
mod models;
|
|
mod realtime;
|
|
mod rollout_history;
|
|
mod thread_list;
|
|
|
|
#[cfg(test)]
|
|
#[path = "app_server_session/collaboration_catalog_tests.rs"]
|
|
mod collaboration_catalog_tests;
|
|
|
|
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;
|
|
|
|
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::AppServerEvent;
|
|
use codex_app_server_client::AppServerPath;
|
|
use codex_app_server_client::AppServerRequestHandle;
|
|
use codex_app_server_client::TypedRequestError;
|
|
use codex_app_server_protocol::Account;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
|
use codex_app_server_protocol::ConfigRequirementsReadResponse;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::GetAccountParams;
|
|
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
|
use codex_app_server_protocol::GetAccountResponse;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::LogoutAccountResponse;
|
|
use codex_app_server_protocol::MemoryResetResponse;
|
|
use codex_app_server_protocol::Model as ApiModel;
|
|
use codex_app_server_protocol::ModelListParams;
|
|
use codex_app_server_protocol::ModelListResponse;
|
|
use codex_app_server_protocol::NewThreadModelDefaults;
|
|
use codex_app_server_protocol::RateLimitSnapshot;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ReviewDelivery;
|
|
use codex_app_server_protocol::ReviewStartParams;
|
|
use codex_app_server_protocol::ReviewStartResponse;
|
|
use codex_app_server_protocol::ReviewTarget;
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::SkillsListParams;
|
|
use codex_app_server_protocol::SkillsListResponse;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams;
|
|
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
|
|
use codex_app_server_protocol::ThreadArchiveParams;
|
|
use codex_app_server_protocol::ThreadArchiveResponse;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
|
|
use codex_app_server_protocol::ThreadCompactStartParams;
|
|
use codex_app_server_protocol::ThreadCompactStartResponse;
|
|
use codex_app_server_protocol::ThreadDeleteParams;
|
|
use codex_app_server_protocol::ThreadDeleteResponse;
|
|
use codex_app_server_protocol::ThreadForkParams;
|
|
use codex_app_server_protocol::ThreadForkResponse;
|
|
use codex_app_server_protocol::ThreadGoalClearParams;
|
|
use codex_app_server_protocol::ThreadGoalClearResponse;
|
|
use codex_app_server_protocol::ThreadGoalGetParams;
|
|
use codex_app_server_protocol::ThreadGoalGetResponse;
|
|
use codex_app_server_protocol::ThreadGoalSetParams;
|
|
use codex_app_server_protocol::ThreadGoalSetResponse;
|
|
use codex_app_server_protocol::ThreadGoalStatus;
|
|
use codex_app_server_protocol::ThreadHistoryMode;
|
|
use codex_app_server_protocol::ThreadInjectItemsParams;
|
|
use codex_app_server_protocol::ThreadInjectItemsResponse;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadLoadedListParams;
|
|
use codex_app_server_protocol::ThreadLoadedListResponse;
|
|
use codex_app_server_protocol::ThreadMemoryMode;
|
|
use codex_app_server_protocol::ThreadMemoryModeSetParams;
|
|
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
|
|
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
|
|
use codex_app_server_protocol::ThreadMetadataUpdateParams;
|
|
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadSetNameParams;
|
|
use codex_app_server_protocol::ThreadSetNameResponse;
|
|
use codex_app_server_protocol::ThreadSettingsUpdateParams;
|
|
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
|
|
use codex_app_server_protocol::ThreadShellCommandParams;
|
|
use codex_app_server_protocol::ThreadShellCommandResponse;
|
|
use codex_app_server_protocol::ThreadSource;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadStartSource;
|
|
use codex_app_server_protocol::ThreadStatusChangedNotification;
|
|
use codex_app_server_protocol::ThreadUnarchiveParams;
|
|
use codex_app_server_protocol::ThreadUnarchiveResponse;
|
|
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
|
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnInterruptParams;
|
|
use codex_app_server_protocol::TurnInterruptResponse;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::TurnSteerParams;
|
|
use codex_app_server_protocol::TurnSteerResponse;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::approvals::GuardianAssessmentEvent;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
|
use codex_protocol::models::ActivePermissionProfile;
|
|
use codex_protocol::models::BaseInstructionsProvenance;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ModelAvailabilityNux;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelServiceTier;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffortPreset;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use color_eyre::eyre::ContextCompat;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
use uuid::Uuid;
|
|
|
|
const JSONRPC_INVALID_REQUEST: i64 = -32600;
|
|
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
|
|
const JSONRPC_INVALID_PARAMS: i64 = -32602;
|
|
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again.";
|
|
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ForkGoalContinuation {
|
|
StartIfIdle,
|
|
DeferUntilNextTurn,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ForkPermissionMode {
|
|
InheritSaved,
|
|
OverrideFromCurrentConfig,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ForkPresentation {
|
|
Regular,
|
|
SideConversation,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ForkConfigSource {
|
|
Local,
|
|
Session,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum ThreadHistorySupport {
|
|
Paginated,
|
|
LegacyOnly,
|
|
}
|
|
|
|
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
|
let message = format!("{context}: {err}");
|
|
color_eyre::Report::new(err).wrap_err(message)
|
|
}
|
|
|
|
pub(crate) fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool {
|
|
if source.code == JSONRPC_METHOD_NOT_FOUND {
|
|
return true;
|
|
}
|
|
|
|
if !matches!(
|
|
source.code,
|
|
JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
let message = source.message.to_ascii_lowercase();
|
|
[
|
|
"historymode",
|
|
"history mode",
|
|
"excludeturns",
|
|
"exclude turns",
|
|
"thread/turns/list",
|
|
"thread/items/list",
|
|
]
|
|
.into_iter()
|
|
.any(|field| message.contains(field))
|
|
|| (message.contains("paginated")
|
|
&& ["unknown variant", "unsupported variant", "invalid enum"]
|
|
.into_iter()
|
|
.any(|error| message.contains(error)))
|
|
}
|
|
|
|
pub(crate) async fn request_thread_start_with_history_fallback(
|
|
request_handle: &AppServerRequestHandle,
|
|
mut request_id: RequestId,
|
|
mut params: ThreadStartParams,
|
|
) -> std::result::Result<(ThreadStartResponse, ThreadHistorySupport, bool), TypedRequestError> {
|
|
let mut history_support = ThreadHistorySupport::Paginated;
|
|
loop {
|
|
match request_handle
|
|
.request_typed(ClientRequest::ThreadStart {
|
|
request_id,
|
|
params: params.clone(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => {
|
|
let task_tools_available = params.dynamic_tools.is_some()
|
|
|| params
|
|
.config
|
|
.as_ref()
|
|
.is_some_and(|config| config.contains_key("mcp_servers.codex_tui"));
|
|
return Ok((response, history_support, task_tools_available));
|
|
}
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if params.history_mode.is_some() && is_history_pagination_unsupported(&source) =>
|
|
{
|
|
params.history_mode = None;
|
|
history_support = ThreadHistorySupport::LegacyOnly;
|
|
request_id = RequestId::String(format!("legacy-thread-start-{}", Uuid::new_v4()));
|
|
}
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if params.dynamic_tools.is_some()
|
|
&& matches!(
|
|
source.code,
|
|
JSONRPC_INVALID_REQUEST | JSONRPC_INVALID_PARAMS
|
|
)
|
|
&& {
|
|
let message = source.message.to_ascii_lowercase();
|
|
["dynamictools", "dynamic tool", "namespace", "inputschema"]
|
|
.into_iter()
|
|
.any(|field| message.contains(field))
|
|
} =>
|
|
{
|
|
tracing::warn!(
|
|
error = %source.message,
|
|
"app server does not support TUI dynamic tools; starting without them"
|
|
);
|
|
params.dynamic_tools = None;
|
|
request_id = RequestId::String(format!("legacy-thread-start-{}", Uuid::new_v4()));
|
|
}
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool {
|
|
source.code == JSONRPC_METHOD_NOT_FOUND
|
|
|| (source.code == JSONRPC_INVALID_REQUEST
|
|
&& source.message.contains(THREAD_SETTINGS_UPDATE_METHOD))
|
|
}
|
|
|
|
/// Data collected during the TUI bootstrap phase that the main event loop
|
|
/// needs to configure the UI, telemetry, and initial rate-limit prefetch.
|
|
///
|
|
/// Rate-limit snapshots are intentionally **not** included here; they are
|
|
/// fetched asynchronously after bootstrap returns so that the TUI can render
|
|
/// its first frame without waiting for the rate-limit round-trip.
|
|
pub(crate) struct AppServerBootstrap {
|
|
pub(crate) duration: Duration,
|
|
pub(crate) account_email: Option<String>,
|
|
pub(crate) auth_mode: Option<TelemetryAuthMode>,
|
|
pub(crate) status_account_display: Option<StatusAccountDisplay>,
|
|
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
|
|
/// Whether the configured model provider needs OpenAI-style auth. Combined
|
|
/// with `has_chatgpt_account` to decide if a startup rate-limit prefetch
|
|
/// should be fired.
|
|
pub(crate) requires_openai_auth: bool,
|
|
pub(crate) default_model: String,
|
|
pub(crate) feedback_audience: FeedbackAudience,
|
|
pub(crate) has_chatgpt_account: bool,
|
|
pub(crate) available_models: Vec<ModelPreset>,
|
|
pub(crate) collaboration_modes: Vec<codex_protocol::config_types::CollaborationModeMask>,
|
|
}
|
|
|
|
pub(crate) struct AppServerSession {
|
|
client: AppServerClient,
|
|
next_request_id: i64,
|
|
history_pagination: HashMap<ThreadId, history::ThreadHistoryPagination>,
|
|
task_tool_threads: HashSet<ThreadId>,
|
|
task_tool_capabilities_dir: Option<AbsolutePathBuf>,
|
|
task_search_generation: Arc<AtomicU64>,
|
|
remote_cwd_override: Option<PathBuf>,
|
|
thread_params_mode: ThreadParamsMode,
|
|
history_support: ThreadHistorySupport,
|
|
thread_settings_update_supported: bool,
|
|
default_model: Option<String>,
|
|
available_models: Vec<ModelPreset>,
|
|
managed_new_thread_defaults: Option<NewThreadModelDefaults>,
|
|
external_agent_config_import_id: Mutex<Option<String>>,
|
|
dynamic_tool_mcp: Option<Arc<DynamicToolMcpServer>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum ThreadParamsMode {
|
|
Embedded,
|
|
Remote,
|
|
}
|
|
|
|
/// 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 {
|
|
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 => None,
|
|
}
|
|
}
|
|
|
|
fn model_provider_from_config(self, config: &Config) -> Option<String> {
|
|
match self {
|
|
Self::Embedded => Some(config.model_provider_id.clone()),
|
|
Self::Remote => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct AppServerStartedThread {
|
|
pub(crate) session: ThreadSessionState,
|
|
pub(crate) turns: Vec<Turn>,
|
|
pub(crate) blocks_direct_input: bool,
|
|
pub(crate) task_tools_available: bool,
|
|
}
|
|
|
|
pub(crate) fn is_active_writer_error(err: &color_eyre::eyre::Report) -> bool {
|
|
err.chain()
|
|
.any(|cause| cause.to_string().contains("already has an active writer"))
|
|
}
|
|
|
|
pub(crate) fn source_agent_path(source: &SessionSource) -> Option<String> {
|
|
match source {
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
|
|
agent_path.clone().map(String::from)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Uses the server capability when available and preserves compatibility with older servers.
|
|
pub(crate) fn thread_blocks_direct_input(thread: &Thread) -> bool {
|
|
thread
|
|
.can_accept_direct_input
|
|
.map(|can_accept| !can_accept)
|
|
.unwrap_or_else(|| source_agent_path(&thread.source).is_some())
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) enum TurnPermissionsOverride {
|
|
/// Leave the app-server thread's sticky permission profile unchanged.
|
|
Preserve,
|
|
/// Select a named or built-in profile by id.
|
|
ActiveProfile(ActivePermissionProfile),
|
|
/// Apply a user-selected legacy/custom permission profile.
|
|
LegacySandbox(PermissionProfile),
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error(
|
|
"the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile"
|
|
)]
|
|
pub(crate) struct UnsupportedLegacyPermissionProfile;
|
|
|
|
impl AppServerSession {
|
|
/// Platform of the app-server process, not necessarily its executor.
|
|
pub(crate) fn app_server_platform_family(&self) -> Option<&str> {
|
|
self.client.platform_family()
|
|
}
|
|
|
|
pub(crate) fn app_server_platform_os(&self) -> Option<&str> {
|
|
self.client.platform_os()
|
|
}
|
|
|
|
pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self {
|
|
Self {
|
|
client,
|
|
next_request_id: 1,
|
|
history_pagination: HashMap::new(),
|
|
task_tool_threads: HashSet::new(),
|
|
task_tool_capabilities_dir: None,
|
|
task_search_generation: Arc::new(AtomicU64::new(0)),
|
|
remote_cwd_override: None,
|
|
thread_params_mode,
|
|
history_support: ThreadHistorySupport::Paginated,
|
|
thread_settings_update_supported: true,
|
|
default_model: None,
|
|
available_models: Vec::new(),
|
|
managed_new_thread_defaults: None,
|
|
external_agent_config_import_id: Mutex::default(),
|
|
dynamic_tool_mcp: None,
|
|
}
|
|
}
|
|
|
|
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.uses_embedded_app_server() {
|
|
return Ok(());
|
|
}
|
|
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 codex_tui 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 thread_start_params = thread_start_params_from_config(
|
|
&config,
|
|
self.thread_params_mode(),
|
|
self.remote_cwd_override(),
|
|
/*session_start_source*/ None,
|
|
);
|
|
self.dynamic_tool_mcp = Some(Arc::new(
|
|
DynamicToolMcpServer::start(
|
|
self.request_handle(),
|
|
thread_start_params,
|
|
app_event_tx,
|
|
status_updates,
|
|
managed_requirement,
|
|
)
|
|
.await?,
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn thread_tool_transport(&self) -> ThreadToolTransport {
|
|
if self.uses_embedded_app_server() {
|
|
ThreadToolTransport::Disabled
|
|
} else 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)
|
|
}
|
|
|
|
pub(crate) fn uses_embedded_app_server(&self) -> bool {
|
|
matches!(&self.client, AppServerClient::InProcess(_))
|
|
}
|
|
|
|
/// Carry capabilities that may exist only in memory when the optional cache is unwritable.
|
|
pub(crate) fn inherit_task_tool_capabilities(&mut self, previous: &Self) {
|
|
self.task_tool_threads.extend(&previous.task_tool_threads);
|
|
}
|
|
|
|
pub(crate) fn task_tools_available(&self, thread_id: ThreadId) -> bool {
|
|
self.task_tool_threads.contains(&thread_id)
|
|
|| self
|
|
.task_tool_capabilities_dir
|
|
.as_ref()
|
|
.is_some_and(|directory| directory.join(thread_id.to_string()).is_file())
|
|
}
|
|
|
|
pub(crate) fn remember_task_tool_thread(&mut self, thread_id: ThreadId) {
|
|
if self.task_tool_threads.insert(thread_id)
|
|
&& let Some(directory) = &self.task_tool_capabilities_dir
|
|
&& let Err(error) = std::fs::create_dir_all(directory)
|
|
.and_then(|()| std::fs::write(directory.join(thread_id.to_string()), []))
|
|
{
|
|
tracing::warn!(%error, %thread_id, "failed to persist task-reference capability");
|
|
}
|
|
}
|
|
|
|
pub(crate) fn task_search_generation(&self) -> Arc<AtomicU64> {
|
|
Arc::clone(&self.task_search_generation)
|
|
}
|
|
|
|
pub(crate) fn codex_home_path(
|
|
&self,
|
|
local_codex_home: &AbsolutePathBuf,
|
|
) -> Option<AppServerPath> {
|
|
self.client.codex_home(local_codex_home)
|
|
}
|
|
|
|
pub(crate) fn server_version(&self) -> Option<&str> {
|
|
let AppServerClient::Remote(client) = &self.client else {
|
|
return None;
|
|
};
|
|
client.server_version()
|
|
}
|
|
|
|
pub(crate) fn server_codex_home(&self) -> Option<&str> {
|
|
let AppServerClient::Remote(client) = &self.client else {
|
|
return None;
|
|
};
|
|
client.codex_home()
|
|
}
|
|
|
|
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
|
|
let started_at = Instant::now();
|
|
let account = self.read_account().await?;
|
|
let mut bootstrap = self.bootstrap_with_account(config, account).await?;
|
|
bootstrap.duration = started_at.elapsed();
|
|
Ok(bootstrap)
|
|
}
|
|
|
|
/// 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> {
|
|
let started_at = Instant::now();
|
|
// `hooks/list` holds the global config queue during startup. Submit models and config
|
|
// requirements together so an uncached model fetch can overlap both config requests.
|
|
let model_request_id = self.next_request_id();
|
|
let requirements_request_id = self.next_request_id();
|
|
let (models, requirements, collaboration_modes) = tokio::try_join!(
|
|
async {
|
|
self.client
|
|
.request_typed::<ModelListResponse>(ClientRequest::ModelList {
|
|
request_id: model_request_id,
|
|
params: ModelListParams {
|
|
cursor: None,
|
|
limit: None,
|
|
include_hidden: Some(true),
|
|
},
|
|
})
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("model/list failed during TUI bootstrap", err)
|
|
})
|
|
},
|
|
async {
|
|
self.client
|
|
.request_typed::<ConfigRequirementsReadResponse>(
|
|
ClientRequest::ConfigRequirementsRead {
|
|
request_id: requirements_request_id,
|
|
params: None,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error(
|
|
"configRequirements/read failed during TUI bootstrap",
|
|
err,
|
|
)
|
|
})
|
|
},
|
|
async { Ok(crate::collaboration_modes::list(self.request_handle()).await) },
|
|
)?;
|
|
self.managed_new_thread_defaults = requirements
|
|
.requirements
|
|
.and_then(|requirements| requirements.models)
|
|
.and_then(|models| models.new_thread);
|
|
let available_models = models
|
|
.data
|
|
.into_iter()
|
|
.map(model_preset_from_api_model)
|
|
.collect::<Vec<_>>();
|
|
let default_model = config
|
|
.model
|
|
.clone()
|
|
.or_else(|| {
|
|
available_models
|
|
.iter()
|
|
.find(|model| model.is_default)
|
|
.map(|model| model.model.clone())
|
|
})
|
|
.or_else(|| available_models.first().map(|model| model.model.clone()))
|
|
.wrap_err("model/list returned no models for TUI bootstrap")?;
|
|
self.default_model = Some(default_model.clone());
|
|
self.available_models = available_models.clone();
|
|
|
|
let (
|
|
account_email,
|
|
auth_mode,
|
|
status_account_display,
|
|
plan_type,
|
|
feedback_audience,
|
|
has_chatgpt_account,
|
|
) = match account.account {
|
|
Some(Account::ApiKey {}) => (
|
|
None,
|
|
Some(TelemetryAuthMode::ApiKey),
|
|
Some(StatusAccountDisplay::ApiKey),
|
|
None,
|
|
FeedbackAudience::External,
|
|
false,
|
|
),
|
|
Some(Account::Chatgpt { email, plan_type }) => {
|
|
let feedback_audience = if email
|
|
.as_deref()
|
|
.is_some_and(|email| email.ends_with("@openai.com"))
|
|
{
|
|
FeedbackAudience::OpenAiEmployee
|
|
} else {
|
|
FeedbackAudience::External
|
|
};
|
|
(
|
|
email.clone(),
|
|
Some(TelemetryAuthMode::Chatgpt),
|
|
Some(StatusAccountDisplay::ChatGpt {
|
|
email,
|
|
plan: Some(plan_type_display_name(plan_type)),
|
|
}),
|
|
Some(plan_type),
|
|
feedback_audience,
|
|
true,
|
|
)
|
|
}
|
|
Some(Account::AmazonBedrock { .. }) => {
|
|
(None, None, None, None, FeedbackAudience::External, false)
|
|
}
|
|
None => (None, None, None, None, FeedbackAudience::External, false),
|
|
};
|
|
Ok(AppServerBootstrap {
|
|
duration: started_at.elapsed(),
|
|
account_email,
|
|
auth_mode,
|
|
status_account_display,
|
|
plan_type,
|
|
requires_openai_auth: account.requires_openai_auth,
|
|
default_model,
|
|
feedback_audience,
|
|
has_chatgpt_account,
|
|
available_models,
|
|
collaboration_modes,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn managed_new_thread_defaults(&self) -> Option<&NewThreadModelDefaults> {
|
|
self.managed_new_thread_defaults.as_ref()
|
|
}
|
|
|
|
pub(crate) fn supports_paginated_history(&self) -> bool {
|
|
self.history_support == ThreadHistorySupport::Paginated
|
|
}
|
|
|
|
/// Fetches the current account info without refreshing the auth token.
|
|
///
|
|
/// Used by both `bootstrap` (to populate the initial UI) and `get_login_status`
|
|
/// (to check auth mode without the overhead of a full bootstrap).
|
|
pub(crate) async fn read_account(&mut self) -> Result<GetAccountResponse> {
|
|
let account_request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::GetAccount {
|
|
request_id: account_request_id,
|
|
params: GetAccountParams {
|
|
refresh_token: false,
|
|
},
|
|
})
|
|
.await
|
|
.map_err(|err| bootstrap_request_error("account/read failed during TUI bootstrap", err))
|
|
}
|
|
|
|
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
|
|
self.client.next_event().await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
|
|
self.start_thread_with_session_start_source(
|
|
&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> {
|
|
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 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;
|
|
}
|
|
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;
|
|
}
|
|
let mut started = started_thread_from_start_response(
|
|
response,
|
|
local_settings,
|
|
config,
|
|
self.thread_params_mode(),
|
|
)
|
|
.await?;
|
|
started.task_tools_available = task_tools_available;
|
|
if task_tools_available {
|
|
self.remember_task_tool_thread(started.session.thread_id);
|
|
}
|
|
Ok(started)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn fork_thread(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<AppServerStartedThread> {
|
|
self.fork_thread_with_permission_mode(
|
|
local_settings,
|
|
config,
|
|
thread_id,
|
|
ForkPermissionMode::InheritSaved,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn fork_thread_with_permission_mode(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
permission_mode: ForkPermissionMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
self.fork_thread_at_with_presentation(
|
|
local_settings,
|
|
config,
|
|
thread_id,
|
|
/*last_turn_id*/ None,
|
|
/*before_turn_id*/ None,
|
|
ForkGoalContinuation::StartIfIdle,
|
|
ForkPresentation::Regular,
|
|
/*selected_profile*/ None,
|
|
permission_mode,
|
|
ForkConfigSource::Local,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "fork position and named permissions are independent"
|
|
)]
|
|
pub(crate) async fn fork_thread_at(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
last_turn_id: Option<String>,
|
|
before_turn_id: Option<String>,
|
|
goal_continuation: ForkGoalContinuation,
|
|
selected_profile: Option<&PermissionProfileSelection>,
|
|
) -> Result<AppServerStartedThread> {
|
|
self.fork_thread_at_with_presentation(
|
|
local_settings,
|
|
config,
|
|
thread_id,
|
|
last_turn_id,
|
|
before_turn_id,
|
|
goal_continuation,
|
|
ForkPresentation::Regular,
|
|
selected_profile,
|
|
ForkPermissionMode::InheritSaved,
|
|
ForkConfigSource::Session,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn fork_side_thread(
|
|
&mut self,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> 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*/ None,
|
|
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> {
|
|
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(),
|
|
)
|
|
};
|
|
if config_source == ForkConfigSource::Session {
|
|
// Active-session config has already adopted the server's roots. Fork does
|
|
// not restore saved roots when omitted, so preserve this explicit selection.
|
|
params.runtime_workspace_roots = Some(config.workspace_roots.clone());
|
|
}
|
|
if self.thread_params_mode() == ThreadParamsMode::Remote
|
|
&& permission_mode == ForkPermissionMode::InheritSaved
|
|
{
|
|
params.approval_policy = None;
|
|
params.approvals_reviewer = None;
|
|
params.sandbox = None;
|
|
params.permissions = None;
|
|
remove_permission_config_overrides(&mut params.config);
|
|
} else if let Some(selected_profile) = selected_profile {
|
|
params.runtime_workspace_roots = None;
|
|
params.permissions = Some(selected_profile.profile_id.clone());
|
|
params.sandbox = None;
|
|
params.approval_policy = selected_profile.approval_policy;
|
|
params.approvals_reviewer = selected_profile.approvals_reviewer.map(Into::into);
|
|
remove_permission_config_overrides(&mut params.config);
|
|
}
|
|
self.thread_tool_transport()
|
|
.configure_mcp(&mut params.config);
|
|
let response: ThreadForkResponse = match self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: params.clone(),
|
|
})
|
|
.await
|
|
{
|
|
Ok(response) => response,
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if params.exclude_turns && is_history_pagination_unsupported(&source) =>
|
|
{
|
|
self.history_support = ThreadHistorySupport::LegacyOnly;
|
|
params.exclude_turns = false;
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadFork { request_id, params })
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/fork failed during TUI bootstrap", err)
|
|
})?
|
|
}
|
|
Err(err) => {
|
|
return Err(bootstrap_request_error(
|
|
"thread/fork failed during TUI bootstrap",
|
|
err,
|
|
));
|
|
}
|
|
};
|
|
let mut response = response;
|
|
if presentation == ForkPresentation::Regular
|
|
&& !response.thread.ephemeral
|
|
&& let Err(error) = self
|
|
.hydrate_initial_thread_history(
|
|
&mut response.thread,
|
|
/*turn_cursor*/ None,
|
|
/*item_cursor*/ None,
|
|
Some(&config),
|
|
Some(local_settings),
|
|
HistoryHydrationScope::Initial,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
thread_id = %response.thread.id,
|
|
error = %error,
|
|
"preserving the created fork after bounded history hydration failed"
|
|
);
|
|
}
|
|
let mut started = started_thread_from_fork_response(
|
|
response,
|
|
local_settings,
|
|
&config,
|
|
self.thread_params_mode(),
|
|
)
|
|
.await?;
|
|
started.session.fork_parent_title = fork_parent.and_then(|thread| thread.name);
|
|
if self.task_tools_available(thread_id) {
|
|
started.task_tools_available = true;
|
|
self.remember_task_tool_thread(started.session.thread_id);
|
|
}
|
|
Ok(started)
|
|
}
|
|
|
|
pub(crate) fn thread_params_mode(&self) -> ThreadParamsMode {
|
|
self.thread_params_mode
|
|
}
|
|
|
|
fn session_config_with_effective_service_tier(&self, config: &Config) -> Config {
|
|
let Some(model) = config.model.as_deref().or(self.default_model.as_deref()) else {
|
|
return config.clone();
|
|
};
|
|
let local_settings = LocalSettings::from(config);
|
|
let mut session_config = config.clone();
|
|
match service_tier_resolution::service_tier_update_for_core(
|
|
config,
|
|
&local_settings.notices,
|
|
model,
|
|
&self.available_models,
|
|
) {
|
|
Some(Some(service_tier)) => {
|
|
session_config.service_tier = Some(service_tier);
|
|
session_config.notices.fast_default_opt_out = None;
|
|
}
|
|
Some(None) => {
|
|
session_config.service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string());
|
|
session_config.notices.fast_default_opt_out = None;
|
|
}
|
|
None => {
|
|
session_config.service_tier = None;
|
|
session_config.notices.fast_default_opt_out = None;
|
|
}
|
|
}
|
|
session_config
|
|
}
|
|
|
|
async fn fork_parent_title_from_app_server(
|
|
&mut self,
|
|
forked_from_id: Option<&str>,
|
|
) -> Option<String> {
|
|
let forked_from_id = forked_from_id?;
|
|
let forked_from_id = match ThreadId::from_string(forked_from_id) {
|
|
Ok(thread_id) => thread_id,
|
|
Err(err) => {
|
|
tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
match self
|
|
.thread_read(forked_from_id, /*include_turns*/ false)
|
|
.await
|
|
{
|
|
Ok(thread) => thread.name,
|
|
Err(err) => {
|
|
tracing::warn!("Failed to read fork parent metadata from app server: {err}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Lists thread ids that the app server currently holds in memory.
|
|
///
|
|
/// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were
|
|
/// spawned before the TUI connected. The caller then fetches full metadata per thread via
|
|
/// `thread_read` and walks the spawn tree.
|
|
pub(crate) async fn thread_loaded_list(
|
|
&mut self,
|
|
params: ThreadLoadedListParams,
|
|
) -> Result<ThreadLoadedListResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadLoadedList { request_id, params })
|
|
.await
|
|
.wrap_err("failed to list loaded threads from app server")
|
|
}
|
|
|
|
pub(crate) async fn thread_read(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
include_turns: bool,
|
|
) -> Result<Thread> {
|
|
let request_id = self.next_request_id();
|
|
let response = self
|
|
.client
|
|
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
|
|
request_id,
|
|
params: ThreadReadParams {
|
|
thread_id: thread_id.to_string(),
|
|
include_turns,
|
|
},
|
|
})
|
|
.await;
|
|
let mut response: ThreadReadResponse = match response {
|
|
Ok(response) => return Ok(response.thread),
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if include_turns
|
|
&& source.message
|
|
== "paginated threads do not support thread/read(includeTurns=true)" =>
|
|
{
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadRead {
|
|
request_id,
|
|
params: ThreadReadParams {
|
|
thread_id: thread_id.to_string(),
|
|
include_turns: false,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/read failed during TUI session lookup")?
|
|
}
|
|
Err(err) => return Err(err).wrap_err("thread/read failed during TUI session lookup"),
|
|
};
|
|
self.hydrate_initial_thread_history(
|
|
&mut response.thread,
|
|
/*turn_cursor*/ None,
|
|
/*item_cursor*/ None,
|
|
/*config*/ None,
|
|
/*local_settings*/ None,
|
|
HistoryHydrationScope::Initial,
|
|
)
|
|
.await?;
|
|
Ok(response.thread)
|
|
}
|
|
|
|
pub(crate) async fn thread_archive(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadArchiveResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadArchive {
|
|
request_id,
|
|
params: ThreadArchiveParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("failed to archive session")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_delete(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadDeleteResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadDelete {
|
|
request_id,
|
|
params: ThreadDeleteParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("failed to delete session")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_unarchive(&mut self, thread_id: ThreadId) -> Result<Thread> {
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadUnarchiveResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadUnarchive {
|
|
request_id,
|
|
params: ThreadUnarchiveParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("failed to unarchive session")?;
|
|
Ok(response.thread)
|
|
}
|
|
|
|
pub(crate) async fn thread_metadata_update_branch(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
branch: String,
|
|
) -> Result<ThreadMetadataUpdateResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadMetadataUpdate {
|
|
request_id,
|
|
params: ThreadMetadataUpdateParams {
|
|
thread_id: thread_id.to_string(),
|
|
project_id: None,
|
|
daybreak_enabled: None,
|
|
git_info: Some(ThreadMetadataGitInfoUpdateParams {
|
|
sha: None,
|
|
branch: Some(Some(branch)),
|
|
origin_url: None,
|
|
}),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/metadata/update failed while syncing git branch")
|
|
}
|
|
|
|
pub(crate) async fn thread_settings_update(
|
|
&mut self,
|
|
params: ThreadSettingsUpdateParams,
|
|
) -> Result<bool> {
|
|
if !self.thread_settings_update_supported {
|
|
return Ok(false);
|
|
}
|
|
let request_id = self.next_request_id();
|
|
match self
|
|
.client
|
|
.request_typed::<ThreadSettingsUpdateResponse>(ClientRequest::ThreadSettingsUpdate {
|
|
request_id,
|
|
params,
|
|
})
|
|
.await
|
|
{
|
|
Ok(_) => Ok(true),
|
|
Err(TypedRequestError::Server { source, .. })
|
|
if is_thread_settings_update_unsupported(&source) =>
|
|
{
|
|
// Older remote app servers can reject this experimental method as
|
|
// method-not-found, experimental-capability-gated, or an unknown
|
|
// request variant. Treat those as a session-level capability
|
|
// downgrade so local TUI setting changes stay best-effort instead
|
|
// of showing an error every time the user changes model, effort,
|
|
// personality, or mode.
|
|
self.thread_settings_update_supported = false;
|
|
Ok(false)
|
|
}
|
|
Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"),
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn thread_inject_items(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
items: Vec<ResponseItem>,
|
|
) -> Result<ThreadInjectItemsResponse> {
|
|
let items = items
|
|
.into_iter()
|
|
.map(serde_json::to_value)
|
|
.collect::<std::result::Result<Vec<_>, _>>()
|
|
.wrap_err("failed to encode thread/inject_items payload")?;
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadInjectItems {
|
|
request_id,
|
|
params: ThreadInjectItemsParams {
|
|
thread_id: thread_id.to_string(),
|
|
items,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/inject_items failed during TUI side conversation setup")
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn turn_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
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>,
|
|
personality: Option<codex_protocol::config_types::Personality>,
|
|
output_schema: Option<serde_json::Value>,
|
|
) -> Result<TurnStartResponse> {
|
|
let request_id = self.next_request_id();
|
|
let (sandbox_policy, permissions) =
|
|
turn_permissions_overrides(permissions_override, cwd.as_path())?;
|
|
self.client
|
|
.request_typed(ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
disabled_plugin_ids: None,
|
|
thread_id: thread_id.to_string(),
|
|
turn_trigger: Some("user".to_string()),
|
|
client_user_message_id: Some(client_user_message_id),
|
|
input: items,
|
|
tool_output: None,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: None,
|
|
environments: None,
|
|
cwd: Some(cwd),
|
|
runtime_workspace_roots: Some(workspace_roots.to_vec()),
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permissions,
|
|
model: Some(model),
|
|
service_tier,
|
|
service_tier_for_turn: None,
|
|
effort,
|
|
summary,
|
|
personality: personality_opt_out_only(personality),
|
|
output_schema,
|
|
collaboration_mode,
|
|
multi_agent_mode: None,
|
|
cyber_access_program: None,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn turn_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
) -> std::result::Result<(), TypedRequestError> {
|
|
let request_id = self.next_request_id();
|
|
let _: TurnInterruptResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id,
|
|
},
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn startup_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> std::result::Result<(), TypedRequestError> {
|
|
self.turn_interrupt(thread_id, String::new()).await
|
|
}
|
|
|
|
pub(crate) async fn turn_steer(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
client_user_message_id: String,
|
|
items: Vec<UserInput>,
|
|
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::TurnSteer {
|
|
request_id,
|
|
params: TurnSteerParams {
|
|
thread_id: thread_id.to_string(),
|
|
client_user_message_id: Some(client_user_message_id),
|
|
input: items,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: None,
|
|
expected_turn_id: turn_id,
|
|
},
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn thread_set_name(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
name: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadSetNameResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadSetName {
|
|
request_id,
|
|
params: ThreadSetNameParams {
|
|
thread_id: thread_id.to_string(),
|
|
name,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/name/set failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_memory_mode_set(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
mode: ThreadMemoryMode,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadMemoryModeSetResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadMemoryModeSet {
|
|
request_id,
|
|
params: ThreadMemoryModeSetParams {
|
|
thread_id: thread_id.to_string(),
|
|
mode,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/memoryMode/set failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn memory_reset(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: MemoryResetResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::MemoryReset {
|
|
request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("memory/reset failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_get(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<ThreadGoalGetResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalGet {
|
|
request_id,
|
|
params: ThreadGoalGetParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/get failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_set(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
objective: Option<String>,
|
|
status: Option<ThreadGoalStatus>,
|
|
token_budget: Option<Option<i64>>,
|
|
) -> Result<ThreadGoalSetResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalSet {
|
|
request_id,
|
|
params: ThreadGoalSetParams {
|
|
thread_id: thread_id.to_string(),
|
|
objective,
|
|
status,
|
|
token_budget,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/set failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_clear(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<ThreadGoalClearResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalClear {
|
|
request_id,
|
|
params: ThreadGoalClearParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/clear failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn logout_account(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: LogoutAccountResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::LogoutAccount {
|
|
request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("account/logout failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadUnsubscribeResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadUnsubscribe {
|
|
request_id,
|
|
params: ThreadUnsubscribeParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/unsubscribe failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_compact_start(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadCompactStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadCompactStart {
|
|
request_id,
|
|
params: ThreadCompactStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/compact/start failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_shell_command(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
command: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadShellCommandResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadShellCommand {
|
|
request_id,
|
|
params: ThreadShellCommandParams {
|
|
thread_id: thread_id.to_string(),
|
|
command,
|
|
timeout_ms: None,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/shellCommand failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_approve_guardian_denied_action(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
event: &GuardianAssessmentEvent,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadApproveGuardianDeniedActionResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadApproveGuardianDeniedAction {
|
|
request_id,
|
|
params: ThreadApproveGuardianDeniedActionParams {
|
|
thread_id: thread_id.to_string(),
|
|
event: serde_json::to_value(event)
|
|
.wrap_err("failed to serialize Auto Review denial event")?,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/approveGuardianDeniedAction failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_background_terminals_clean(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadBackgroundTerminalsCleanResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadBackgroundTerminalsClean {
|
|
request_id,
|
|
params: ThreadBackgroundTerminalsCleanParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/backgroundTerminals/clean failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn review_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
target: ReviewTarget,
|
|
) -> Result<ReviewStartResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ReviewStart {
|
|
request_id,
|
|
params: ReviewStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
target,
|
|
delivery: Some(ReviewDelivery::Inline),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("review/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn skills_list(
|
|
&mut self,
|
|
params: SkillsListParams,
|
|
) -> Result<SkillsListResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::SkillsList { request_id, params })
|
|
.await
|
|
.wrap_err("skills/list failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn reload_user_config(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ConfigWriteResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ConfigBatchWrite {
|
|
request_id,
|
|
params: ConfigBatchWriteParams {
|
|
edits: Vec::new(),
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("config/batchWrite failed while reloading user config in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn reject_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
error: JSONRPCErrorError,
|
|
) -> std::io::Result<()> {
|
|
self.client.reject_server_request(request_id, error).await
|
|
}
|
|
|
|
pub(crate) async fn resolve_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
result: serde_json::Value,
|
|
) -> std::io::Result<()> {
|
|
self.client.resolve_server_request(request_id, result).await
|
|
}
|
|
|
|
pub(crate) async fn shutdown(self) -> std::io::Result<()> {
|
|
self.client.shutdown().await
|
|
}
|
|
|
|
pub(crate) fn request_handle(&self) -> AppServerRequestHandle {
|
|
self.client.request_handle()
|
|
}
|
|
|
|
pub(crate) fn next_request_id(&mut self) -> RequestId {
|
|
let request_id = self.next_request_id;
|
|
self.next_request_id += 1;
|
|
RequestId::Integer(request_id)
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn start_thread_with_request_handle(
|
|
request_handle: AppServerRequestHandle,
|
|
local_settings: &LocalSettings,
|
|
config: Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<PathBuf>,
|
|
thread_tool_transport: ThreadToolTransport,
|
|
) -> Result<AppServerStartedThread> {
|
|
let request_id = RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4()));
|
|
let mut params = thread_start_params_from_config(
|
|
&config,
|
|
thread_params_mode,
|
|
remote_cwd_override.as_deref(),
|
|
/*session_start_source*/ None,
|
|
);
|
|
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 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),
|
|
}),
|
|
Some(AuthMode::Headers)
|
|
| Some(AuthMode::BedrockApiKey)
|
|
| Some(AuthMode::BedrockAccessKeys) => None,
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
|
|
let upgrade = model.upgrade.map(|upgrade_id| {
|
|
let upgrade_info = model.upgrade_info.clone();
|
|
ModelUpgrade {
|
|
id: upgrade_id,
|
|
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: model.supports_personality,
|
|
additional_speed_tiers: model.additional_speed_tiers,
|
|
service_tiers: model
|
|
.service_tiers
|
|
.into_iter()
|
|
.map(|service_tier| ModelServiceTier {
|
|
id: service_tier.id,
|
|
name: service_tier.name,
|
|
description: service_tier.description,
|
|
})
|
|
.collect(),
|
|
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())
|
|
}
|
|
|
|
// Keep the explicit Bridge opt-out while retiring Friendly/Pragmatic selection.
|
|
pub(crate) fn personality_opt_out_only(personality: Option<Personality>) -> Option<Personality> {
|
|
personality.filter(|personality| *personality == Personality::None)
|
|
}
|
|
|
|
fn config_request_overrides_from_config(
|
|
config: &Config,
|
|
) -> 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"
|
|
| "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),
|
|
);
|
|
insert(
|
|
"model_reasoning_summary",
|
|
config
|
|
.model_reasoning_summary
|
|
.map(|summary| summary.to_string()),
|
|
);
|
|
insert(
|
|
"model_verbosity",
|
|
config
|
|
.model_verbosity
|
|
.map(|verbosity| verbosity.to_string()),
|
|
);
|
|
insert(
|
|
"personality",
|
|
personality_opt_out_only(config.personality).map(|personality| personality.to_string()),
|
|
);
|
|
insert(
|
|
"web_search",
|
|
Some(config.web_search_mode.value().to_string()),
|
|
);
|
|
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 new_thread_reasoning_overrides(config: &Config) -> Option<HashMap<String, serde_json::Value>> {
|
|
let mut overrides = config_request_overrides_from_config(config).unwrap_or_default();
|
|
let summary = config
|
|
.model_reasoning_summary
|
|
.unwrap_or(codex_protocol::config_types::ReasoningSummary::None);
|
|
overrides.insert(
|
|
"model_reasoning_summary".to_string(),
|
|
serde_json::Value::String(summary.to_string()),
|
|
);
|
|
let explicit_feature = config
|
|
.config_layer_stack
|
|
.effective_config()
|
|
.get("features")
|
|
.and_then(|features| features.get("concurrent_reasoning_summaries"))
|
|
.and_then(toml::Value::as_bool);
|
|
let features = overrides
|
|
.entry("features".to_string())
|
|
.or_insert_with(|| serde_json::json!({}));
|
|
if let Some(features) = features.as_object_mut() {
|
|
features.insert(
|
|
"concurrent_reasoning_summaries".to_string(),
|
|
serde_json::Value::Bool(
|
|
summary != codex_protocol::config_types::ReasoningSummary::None
|
|
&& explicit_feature.unwrap_or(/*default*/ false),
|
|
),
|
|
);
|
|
}
|
|
Some(overrides)
|
|
}
|
|
|
|
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 {
|
|
let permissions = permissions_selection_from_config(config, thread_params_mode);
|
|
let sandbox = permissions
|
|
.is_none()
|
|
.then(|| {
|
|
sandbox_mode_from_permission_profile(
|
|
&config.permissions.effective_permission_profile(),
|
|
config.cwd.as_path(),
|
|
)
|
|
})
|
|
.flatten();
|
|
ThreadStartParams {
|
|
model: config.model.clone(),
|
|
model_provider: thread_params_mode.model_provider_from_config(config),
|
|
service_tier: service_tier_override_from_config(config),
|
|
cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: 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: match thread_params_mode {
|
|
ThreadParamsMode::Embedded => new_thread_reasoning_overrides(config),
|
|
ThreadParamsMode::Remote => config_request_overrides_from_config(config),
|
|
},
|
|
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,
|
|
) -> ThreadResumeParams {
|
|
if 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);
|
|
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(),
|
|
thread_params_mode.model_provider_from_config(&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 {
|
|
// Resuming restores the server's saved permission settings, including named profiles.
|
|
params.approval_policy = None;
|
|
params.approvals_reviewer = None;
|
|
params.sandbox = None;
|
|
params.permissions = None;
|
|
remove_permission_config_overrides(&mut params.config);
|
|
}
|
|
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: thread_params_mode.model_provider_from_config(&config),
|
|
service_tier: service_tier_override_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
|
|
runtime_workspace_roots: 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),
|
|
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 => {
|
|
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,
|
|
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_resume_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_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,
|
|
);
|
|
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(),
|
|
config.personality,
|
|
local_settings,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_resume_response(
|
|
response: &ThreadResumeResponse,
|
|
local_settings: &LocalSettings,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = if matches!(thread_params_mode, ThreadParamsMode::Embedded)
|
|
&& response.active_permission_profile.is_none()
|
|
{
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
|
|
&response.sandbox.to_core(),
|
|
response.cwd.as_path(),
|
|
)
|
|
} else {
|
|
display_permission_profile_from_thread_response(
|
|
&response.sandbox,
|
|
response.cwd.as_path(),
|
|
config,
|
|
thread_params_mode,
|
|
)
|
|
};
|
|
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(),
|
|
config.personality,
|
|
local_settings,
|
|
)
|
|
.await?;
|
|
session.collaboration_mode = response.collaboration_mode.clone().map(Box::new);
|
|
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,
|
|
);
|
|
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(),
|
|
config.personality,
|
|
local_settings,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn display_permission_profile_from_thread_response(
|
|
sandbox: &codex_app_server_protocol::SandboxPolicy,
|
|
cwd: &std::path::Path,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> PermissionProfile {
|
|
match thread_params_mode {
|
|
ThreadParamsMode::Embedded
|
|
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 => {
|
|
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>,
|
|
personality: Option<codex_protocol::config_types::Personality>,
|
|
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 {
|
|
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,
|
|
personality,
|
|
message_history: Some(MessageHistoryMetadata {
|
|
log_id,
|
|
entry_count,
|
|
}),
|
|
network_proxy: None,
|
|
rollout_path,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn app_server_rate_limit_snapshots(
|
|
response: GetAccountRateLimitsResponse,
|
|
) -> Vec<RateLimitSnapshot> {
|
|
let primary_limit_id = response.rate_limits.limit_id.clone();
|
|
let mut snapshots = vec![response.rate_limits];
|
|
if let Some(by_limit_id) = response.rate_limits_by_limit_id {
|
|
snapshots.extend(by_limit_id.into_iter().filter_map(|(limit_id, snapshot)| {
|
|
if primary_limit_id.as_deref().is_some_and(|primary_limit_id| {
|
|
primary_limit_id == limit_id
|
|
|| Some(primary_limit_id) == snapshot.limit_id.as_deref()
|
|
}) {
|
|
None
|
|
} else {
|
|
Some(snapshot)
|
|
}
|
|
}));
|
|
}
|
|
snapshots
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[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::ThreadStatus;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_features::Feature;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::config_types::Verbosity;
|
|
use codex_protocol::config_types::WebSearchMode;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
|
use codex_protocol::models::ManagedFileSystemPermissions;
|
|
use codex_protocol::openai_models::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_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use codex_utils_path_uri::LegacyAppPathString;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
|
|
async fn build_config(temp_dir: &TempDir) -> Config {
|
|
ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().to_path_buf())
|
|
.build()
|
|
.await
|
|
.expect("config should build")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bootstrap_reuses_prefetched_account_without_another_account_read() -> Result<()> {
|
|
let codex_home = tempfile::tempdir()?;
|
|
let 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;
|
|
let account = GetAccountResponse {
|
|
workspace_routing: None,
|
|
account: Some(Account::Chatgpt {
|
|
email: Some("teammate@openai.com".to_string()),
|
|
plan_type: codex_protocol::account::PlanType::Plus,
|
|
}),
|
|
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,
|
|
)
|
|
);
|
|
|
|
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(())
|
|
}
|
|
|
|
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 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, Some(config.model_provider_id));
|
|
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,
|
|
);
|
|
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,
|
|
);
|
|
|
|
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,
|
|
);
|
|
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.personality = Some(Personality::Pragmatic);
|
|
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,
|
|
);
|
|
let fork = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
|
|
let expected_service_tier = Some(Some(ServiceTier::Fast.request_value().to_string()));
|
|
assert_eq!(start.service_tier, expected_service_tier);
|
|
assert_eq!(resume.service_tier, expected_service_tier);
|
|
assert_eq!(fork.service_tier, expected_service_tier);
|
|
let string = |value: &str| serde_json::Value::String(value.to_string());
|
|
let expected_config = HashMap::from([
|
|
("model_reasoning_effort".to_string(), string("high")),
|
|
("model_reasoning_summary".to_string(), string("detailed")),
|
|
("model_verbosity".to_string(), string("low")),
|
|
("web_search".to_string(), string("disabled")),
|
|
("bypass_hook_trust".to_string(), true.into()),
|
|
]);
|
|
let mut expected_start_config = expected_config.clone();
|
|
expected_start_config.insert(
|
|
"features".to_string(),
|
|
serde_json::json!({"concurrent_reasoning_summaries": false}),
|
|
);
|
|
assert_eq!(start.config, Some(expected_start_config));
|
|
assert_eq!(resume.config, Some(expected_config.clone()));
|
|
assert_eq!(fork.config, Some(expected_config));
|
|
}
|
|
|
|
#[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);
|
|
config.personality = Some(Personality::Pragmatic);
|
|
|
|
let params = thread_resume_params_from_config(
|
|
config,
|
|
ThreadId::new(),
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
ResumeModelSettings::RestoreFromThread,
|
|
);
|
|
|
|
assert_eq!(params.model, None);
|
|
assert_eq!(params.model_provider, None);
|
|
assert_eq!(
|
|
params.config,
|
|
Some(HashMap::from([
|
|
(
|
|
"model_reasoning_summary".to_string(),
|
|
serde_json::Value::String("detailed".to_string()),
|
|
),
|
|
(
|
|
"web_search".to_string(),
|
|
serde_json::Value::String("cached".to_string()),
|
|
),
|
|
]))
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
);
|
|
|
|
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,
|
|
)
|
|
.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,
|
|
)
|
|
.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 config_request_overrides_only_forward_explicit_personality_opt_out() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.personality = None;
|
|
|
|
let implicit_overrides =
|
|
config_request_overrides_from_config(&config).expect("config overrides");
|
|
|
|
assert!(!implicit_overrides.contains_key("personality"));
|
|
|
|
for personality in [Personality::Friendly, Personality::Pragmatic] {
|
|
config.personality = Some(personality);
|
|
let ordinary_overrides =
|
|
config_request_overrides_from_config(&config).expect("config overrides");
|
|
assert!(!ordinary_overrides.contains_key("personality"));
|
|
}
|
|
|
|
config.personality = Some(Personality::None);
|
|
let explicit_overrides =
|
|
config_request_overrides_from_config(&config).expect("config overrides");
|
|
|
|
assert_eq!(
|
|
explicit_overrides.get("personality"),
|
|
Some(&serde_json::Value::String("none".to_string()))
|
|
);
|
|
|
|
for mode in [ThreadParamsMode::Embedded, ThreadParamsMode::Remote] {
|
|
let start = thread_start_params_from_config(
|
|
&config, mode, /*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
let resume = thread_resume_params_from_config(
|
|
config.clone(),
|
|
ThreadId::new(),
|
|
mode,
|
|
/*remote_cwd_override*/ None,
|
|
ResumeModelSettings::OverrideFromCurrentConfig,
|
|
);
|
|
let fork = thread_fork_params_from_config(
|
|
config.clone(),
|
|
ThreadId::new(),
|
|
mode,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
for overrides in [start.config, resume.config, fork.config] {
|
|
assert_eq!(
|
|
overrides
|
|
.as_ref()
|
|
.and_then(|overrides| overrides.get("personality")),
|
|
Some(&serde_json::Value::String("none".to_string()))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_params_forward_instruction_overrides() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.base_instructions = Some("Base override.".to_string());
|
|
config.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 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)
|
|
.await?;
|
|
|
|
assert_eq!(regular.turns.len(), 1);
|
|
assert!(matches!(
|
|
regular.turns[0].items.as_slice(),
|
|
[codex_app_server_protocol::ThreadItem::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,
|
|
);
|
|
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,
|
|
);
|
|
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(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![
|
|
codex_app_server_protocol::ThreadItem::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(),
|
|
}],
|
|
},
|
|
codex_app_server_protocol::ThreadItem::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),
|
|
&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),
|
|
&embedded_config,
|
|
ThreadParamsMode::Embedded,
|
|
)
|
|
.await
|
|
.expect("embedded resume response should map");
|
|
assert_eq!(started.session.permission_profile, read_only_profile);
|
|
|
|
let mut empty_roots_response = response;
|
|
empty_roots_response.runtime_workspace_roots = Vec::new();
|
|
let started = started_thread_from_resume_response(
|
|
empty_roots_response,
|
|
&LocalSettings::from(&config),
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
)
|
|
.await
|
|
.expect("resume response should map");
|
|
assert_eq!(started.session.runtime_workspace_roots, Vec::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_thread_response_uses_legacy_sandbox_fallback() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let cwd = test_path_buf("/tmp/project").abs();
|
|
let sandbox = PermissionProfile::read_only()
|
|
.to_legacy_sandbox_policy(cwd.as_path())
|
|
.expect("read-only profile must be legacy-compatible")
|
|
.into();
|
|
|
|
assert_eq!(
|
|
display_permission_profile_from_thread_response(
|
|
&sandbox,
|
|
cwd.as_path(),
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
),
|
|
PermissionProfile::read_only()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn embedded_thread_response_uses_local_config_profile() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
|
|
..ConfigOverrides::default()
|
|
})
|
|
.build()
|
|
.await
|
|
.expect("config should build");
|
|
let cwd = test_path_buf("/tmp/project").abs();
|
|
|
|
assert_eq!(
|
|
display_permission_profile_from_thread_response(
|
|
&codex_app_server_protocol::SandboxPolicy::DangerFullAccess,
|
|
cwd.as_path(),
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
),
|
|
PermissionProfile::read_only()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_populates_history_metadata() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
|
|
let history_config =
|
|
codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);
|
|
|
|
codex_message_history::append_entry("older", &thread_id, &history_config)
|
|
.await
|
|
.expect("history append should succeed");
|
|
codex_message_history::append_entry("newer", &thread_id, &history_config)
|
|
.await
|
|
.expect("history append should succeed");
|
|
|
|
let session = thread_session_state_from_thread_response(
|
|
&thread_id.to_string(),
|
|
crate::app::WindowsSandboxHost::Local,
|
|
/*forked_from_id*/ None,
|
|
Some("restore".to_string()),
|
|
/*rollout_path*/ None,
|
|
"gpt-5.4".to_string(),
|
|
"openai".to_string(),
|
|
/*service_tier*/ None,
|
|
AskForApproval::Never,
|
|
codex_protocol::config_types::ApprovalsReviewer::User,
|
|
PermissionProfile::read_only(),
|
|
/*active_permission_profile*/ None,
|
|
test_path_buf("/tmp/project").abs(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
/*reasoning_effort*/ None,
|
|
config.personality,
|
|
&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,
|
|
config.personality,
|
|
&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"
|
|
));
|
|
}
|
|
}
|