mirror of
https://github.com/openai/codex.git
synced 2026-05-16 17:23:57 +00:00
2125 lines
77 KiB
Rust
2125 lines
77 KiB
Rust
//! App-server session facade used by the TUI event loop.
|
|
//!
|
|
//! This module owns the typed JSON-RPC calls needed by the TUI and keeps
|
|
//! request/response plumbing out of `App` and `ChatWidget`.
|
|
|
|
use crate::bottom_pane::FeedbackAudience;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::session_state::MessageHistoryMetadata;
|
|
use crate::session_state::ThreadSessionState;
|
|
use crate::status::StatusAccountDisplay;
|
|
use crate::status::plan_type_display_name;
|
|
use codex_app_server_client::AppServerClient;
|
|
use codex_app_server_client::AppServerEvent;
|
|
use codex_app_server_client::AppServerRequestHandle;
|
|
use codex_app_server_client::TypedRequestError;
|
|
use codex_app_server_protocol::Account;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
use codex_app_server_protocol::ConfigBatchWriteParams;
|
|
use codex_app_server_protocol::ConfigWriteResponse;
|
|
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
|
|
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
|
|
use codex_app_server_protocol::ExternalAgentConfigImportParams;
|
|
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
|
|
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
|
|
use codex_app_server_protocol::GetAccountParams;
|
|
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
|
use codex_app_server_protocol::GetAccountResponse;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::LogoutAccountResponse;
|
|
use codex_app_server_protocol::MemoryResetResponse;
|
|
use codex_app_server_protocol::Model as ApiModel;
|
|
use codex_app_server_protocol::ModelListParams;
|
|
use codex_app_server_protocol::ModelListResponse;
|
|
use codex_app_server_protocol::PermissionProfileSelectionParams;
|
|
use codex_app_server_protocol::RateLimitSnapshot;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ReviewDelivery;
|
|
use codex_app_server_protocol::ReviewStartParams;
|
|
use codex_app_server_protocol::ReviewStartResponse;
|
|
use codex_app_server_protocol::ReviewTarget;
|
|
use codex_app_server_protocol::SkillsListParams;
|
|
use codex_app_server_protocol::SkillsListResponse;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams;
|
|
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
|
|
use codex_app_server_protocol::ThreadCompactStartParams;
|
|
use codex_app_server_protocol::ThreadCompactStartResponse;
|
|
use codex_app_server_protocol::ThreadForkParams;
|
|
use codex_app_server_protocol::ThreadForkResponse;
|
|
use codex_app_server_protocol::ThreadGoalClearParams;
|
|
use codex_app_server_protocol::ThreadGoalClearResponse;
|
|
use codex_app_server_protocol::ThreadGoalGetParams;
|
|
use codex_app_server_protocol::ThreadGoalGetResponse;
|
|
use codex_app_server_protocol::ThreadGoalSetParams;
|
|
use codex_app_server_protocol::ThreadGoalSetResponse;
|
|
use codex_app_server_protocol::ThreadGoalStatus;
|
|
use codex_app_server_protocol::ThreadInjectItemsParams;
|
|
use codex_app_server_protocol::ThreadInjectItemsResponse;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadLoadedListParams;
|
|
use codex_app_server_protocol::ThreadLoadedListResponse;
|
|
use codex_app_server_protocol::ThreadMemoryMode;
|
|
use codex_app_server_protocol::ThreadMemoryModeSetParams;
|
|
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
|
use codex_app_server_protocol::ThreadRealtimeStartParams;
|
|
use codex_app_server_protocol::ThreadRealtimeStartResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
|
use codex_app_server_protocol::ThreadRealtimeStopParams;
|
|
use codex_app_server_protocol::ThreadRealtimeStopResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadRollbackParams;
|
|
use codex_app_server_protocol::ThreadRollbackResponse;
|
|
use codex_app_server_protocol::ThreadSetNameParams;
|
|
use codex_app_server_protocol::ThreadSetNameResponse;
|
|
use codex_app_server_protocol::ThreadShellCommandParams;
|
|
use codex_app_server_protocol::ThreadShellCommandResponse;
|
|
use codex_app_server_protocol::ThreadSource;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadStartSource;
|
|
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
|
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnInterruptParams;
|
|
use codex_app_server_protocol::TurnInterruptResponse;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::TurnSteerParams;
|
|
use codex_app_server_protocol::TurnSteerResponse;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::approvals::GuardianAssessmentEvent;
|
|
use codex_protocol::models::ActivePermissionProfile;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ModelAvailabilityNux;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelServiceTier;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffortPreset;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use color_eyre::eyre::ContextCompat;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
|
|
color_eyre::eyre::eyre!("{context}: {err}")
|
|
}
|
|
|
|
/// Data collected during the TUI bootstrap phase that the main event loop
|
|
/// needs to configure the UI, telemetry, and initial rate-limit prefetch.
|
|
///
|
|
/// Rate-limit snapshots are intentionally **not** included here; they are
|
|
/// fetched asynchronously after bootstrap returns so that the TUI can render
|
|
/// its first frame without waiting for the rate-limit round-trip.
|
|
pub(crate) struct AppServerBootstrap {
|
|
pub(crate) account_email: Option<String>,
|
|
pub(crate) auth_mode: Option<TelemetryAuthMode>,
|
|
pub(crate) status_account_display: Option<StatusAccountDisplay>,
|
|
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
|
|
/// Whether the configured model provider needs OpenAI-style auth. Combined
|
|
/// with `has_chatgpt_account` to decide if a startup rate-limit prefetch
|
|
/// should be fired.
|
|
pub(crate) requires_openai_auth: bool,
|
|
pub(crate) default_model: String,
|
|
pub(crate) feedback_audience: FeedbackAudience,
|
|
pub(crate) has_chatgpt_account: bool,
|
|
pub(crate) available_models: Vec<ModelPreset>,
|
|
}
|
|
|
|
pub(crate) struct AppServerSession {
|
|
client: AppServerClient,
|
|
next_request_id: i64,
|
|
remote_cwd_override: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ThreadParamsMode {
|
|
Embedded,
|
|
Remote,
|
|
}
|
|
|
|
impl ThreadParamsMode {
|
|
fn model_provider_from_config(self, config: &Config) -> Option<String> {
|
|
match self {
|
|
Self::Embedded => Some(config.model_provider_id.clone()),
|
|
Self::Remote => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) struct AppServerStartedThread {
|
|
pub(crate) session: ThreadSessionState,
|
|
pub(crate) turns: Vec<Turn>,
|
|
}
|
|
|
|
impl AppServerSession {
|
|
pub(crate) fn new(client: AppServerClient) -> Self {
|
|
Self {
|
|
client,
|
|
next_request_id: 1,
|
|
remote_cwd_override: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn with_remote_cwd_override(mut self, remote_cwd_override: Option<PathBuf>) -> Self {
|
|
self.remote_cwd_override = remote_cwd_override;
|
|
self
|
|
}
|
|
|
|
pub(crate) fn remote_cwd_override(&self) -> Option<&std::path::Path> {
|
|
self.remote_cwd_override.as_deref()
|
|
}
|
|
|
|
pub(crate) fn is_remote(&self) -> bool {
|
|
matches!(self.client, AppServerClient::Remote(_))
|
|
}
|
|
|
|
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
|
|
let account = self.read_account().await?;
|
|
let model_request_id = self.next_request_id();
|
|
let models: ModelListResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ModelList {
|
|
request_id: model_request_id,
|
|
params: ModelListParams {
|
|
cursor: None,
|
|
limit: None,
|
|
include_hidden: Some(true),
|
|
},
|
|
})
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("model/list failed during TUI bootstrap", err)
|
|
})?;
|
|
let available_models = models
|
|
.data
|
|
.into_iter()
|
|
.map(model_preset_from_api_model)
|
|
.collect::<Vec<_>>();
|
|
let default_model = config
|
|
.model
|
|
.clone()
|
|
.or_else(|| {
|
|
available_models
|
|
.iter()
|
|
.find(|model| model.is_default)
|
|
.map(|model| model.model.clone())
|
|
})
|
|
.or_else(|| available_models.first().map(|model| model.model.clone()))
|
|
.wrap_err("model/list returned no models for TUI bootstrap")?;
|
|
|
|
let (
|
|
account_email,
|
|
auth_mode,
|
|
status_account_display,
|
|
plan_type,
|
|
feedback_audience,
|
|
has_chatgpt_account,
|
|
) = match account.account {
|
|
Some(Account::ApiKey {}) => (
|
|
None,
|
|
Some(TelemetryAuthMode::ApiKey),
|
|
Some(StatusAccountDisplay::ApiKey),
|
|
None,
|
|
FeedbackAudience::External,
|
|
false,
|
|
),
|
|
Some(Account::Chatgpt { email, plan_type }) => {
|
|
let feedback_audience = if email.ends_with("@openai.com") {
|
|
FeedbackAudience::OpenAiEmployee
|
|
} else {
|
|
FeedbackAudience::External
|
|
};
|
|
(
|
|
Some(email.clone()),
|
|
Some(TelemetryAuthMode::Chatgpt),
|
|
Some(StatusAccountDisplay::ChatGpt {
|
|
email: Some(email),
|
|
plan: Some(plan_type_display_name(plan_type)),
|
|
}),
|
|
Some(plan_type),
|
|
feedback_audience,
|
|
true,
|
|
)
|
|
}
|
|
Some(Account::AmazonBedrock {}) => {
|
|
(None, None, None, None, FeedbackAudience::External, false)
|
|
}
|
|
None => (None, None, None, None, FeedbackAudience::External, false),
|
|
};
|
|
Ok(AppServerBootstrap {
|
|
account_email,
|
|
auth_mode,
|
|
status_account_display,
|
|
plan_type,
|
|
requires_openai_auth: account.requires_openai_auth,
|
|
default_model,
|
|
feedback_audience,
|
|
has_chatgpt_account,
|
|
available_models,
|
|
})
|
|
}
|
|
|
|
/// Fetches the current account info without refreshing the auth token.
|
|
///
|
|
/// Used by both `bootstrap` (to populate the initial UI) and `get_login_status`
|
|
/// (to check auth mode without the overhead of a full bootstrap).
|
|
pub(crate) async fn read_account(&mut self) -> Result<GetAccountResponse> {
|
|
let account_request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::GetAccount {
|
|
request_id: account_request_id,
|
|
params: GetAccountParams {
|
|
refresh_token: false,
|
|
},
|
|
})
|
|
.await
|
|
.map_err(|err| bootstrap_request_error("account/read failed during TUI bootstrap", err))
|
|
}
|
|
|
|
pub(crate) async fn external_agent_config_detect(
|
|
&mut self,
|
|
params: ExternalAgentConfigDetectParams,
|
|
) -> Result<ExternalAgentConfigDetectResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
|
|
.await
|
|
.wrap_err("externalAgentConfig/detect failed during TUI startup")
|
|
}
|
|
|
|
pub(crate) async fn external_agent_config_import(
|
|
&mut self,
|
|
migration_items: Vec<ExternalAgentConfigMigrationItem>,
|
|
) -> Result<ExternalAgentConfigImportResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ExternalAgentConfigImport {
|
|
request_id,
|
|
params: ExternalAgentConfigImportParams { migration_items },
|
|
})
|
|
.await
|
|
.wrap_err("externalAgentConfig/import failed during TUI startup")
|
|
}
|
|
|
|
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
|
|
self.client.next_event().await
|
|
}
|
|
|
|
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
|
|
self.start_thread_with_session_start_source(config, /*session_start_source*/ None)
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn start_thread_with_session_start_source(
|
|
&mut self,
|
|
config: &Config,
|
|
session_start_source: Option<ThreadStartSource>,
|
|
) -> Result<AppServerStartedThread> {
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadStart {
|
|
request_id,
|
|
params: thread_start_params_from_config(
|
|
config,
|
|
self.thread_params_mode(),
|
|
self.remote_cwd_override.as_deref(),
|
|
session_start_source,
|
|
),
|
|
})
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/start failed during TUI bootstrap", err)
|
|
})?;
|
|
started_thread_from_start_response(response, config, self.thread_params_mode()).await
|
|
}
|
|
|
|
pub(crate) async fn resume_thread(
|
|
&mut self,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<AppServerStartedThread> {
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadResumeResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadResume {
|
|
request_id,
|
|
params: thread_resume_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
self.thread_params_mode(),
|
|
self.remote_cwd_override.as_deref(),
|
|
),
|
|
})
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/resume failed during TUI bootstrap", err)
|
|
})?;
|
|
let fork_parent_title = self
|
|
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
|
|
.await;
|
|
let mut started =
|
|
started_thread_from_resume_response(response, &config, self.thread_params_mode())
|
|
.await?;
|
|
started.session.fork_parent_title = fork_parent_title;
|
|
Ok(started)
|
|
}
|
|
|
|
pub(crate) async fn fork_thread(
|
|
&mut self,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<AppServerStartedThread> {
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadForkResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: thread_fork_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
self.thread_params_mode(),
|
|
self.remote_cwd_override.as_deref(),
|
|
),
|
|
})
|
|
.await
|
|
.map_err(|err| {
|
|
bootstrap_request_error("thread/fork failed during TUI bootstrap", err)
|
|
})?;
|
|
let fork_parent_title = self
|
|
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
|
|
.await;
|
|
let mut started =
|
|
started_thread_from_fork_response(response, &config, self.thread_params_mode()).await?;
|
|
started.session.fork_parent_title = fork_parent_title;
|
|
Ok(started)
|
|
}
|
|
|
|
fn thread_params_mode(&self) -> ThreadParamsMode {
|
|
match &self.client {
|
|
AppServerClient::InProcess(_) => ThreadParamsMode::Embedded,
|
|
AppServerClient::Remote(_) => ThreadParamsMode::Remote,
|
|
}
|
|
}
|
|
|
|
async fn fork_parent_title_from_app_server(
|
|
&mut self,
|
|
forked_from_id: Option<&str>,
|
|
) -> Option<String> {
|
|
let forked_from_id = forked_from_id?;
|
|
let forked_from_id = match ThreadId::from_string(forked_from_id) {
|
|
Ok(thread_id) => thread_id,
|
|
Err(err) => {
|
|
tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
match self
|
|
.thread_read(forked_from_id, /*include_turns*/ false)
|
|
.await
|
|
{
|
|
Ok(thread) => thread.name,
|
|
Err(err) => {
|
|
tracing::warn!("Failed to read fork parent metadata from app server: {err}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn thread_list(
|
|
&mut self,
|
|
params: ThreadListParams,
|
|
) -> Result<ThreadListResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadList { request_id, params })
|
|
.await
|
|
.wrap_err("thread/list failed during TUI session lookup")
|
|
}
|
|
|
|
/// Lists thread ids that the app server currently holds in memory.
|
|
///
|
|
/// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were
|
|
/// spawned before the TUI connected. The caller then fetches full metadata per thread via
|
|
/// `thread_read` and walks the spawn tree.
|
|
pub(crate) async fn thread_loaded_list(
|
|
&mut self,
|
|
params: ThreadLoadedListParams,
|
|
) -> Result<ThreadLoadedListResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadLoadedList { request_id, params })
|
|
.await
|
|
.wrap_err("failed to list loaded threads from app server")
|
|
}
|
|
|
|
pub(crate) async fn thread_read(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
include_turns: bool,
|
|
) -> Result<Thread> {
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadReadResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRead {
|
|
request_id,
|
|
params: ThreadReadParams {
|
|
thread_id: thread_id.to_string(),
|
|
include_turns,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/read failed during TUI session lookup")?;
|
|
Ok(response.thread)
|
|
}
|
|
|
|
pub(crate) async fn thread_inject_items(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
items: Vec<ResponseItem>,
|
|
) -> Result<ThreadInjectItemsResponse> {
|
|
let items = items
|
|
.into_iter()
|
|
.map(serde_json::to_value)
|
|
.collect::<std::result::Result<Vec<_>, _>>()
|
|
.wrap_err("failed to encode thread/inject_items payload")?;
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadInjectItems {
|
|
request_id,
|
|
params: ThreadInjectItemsParams {
|
|
thread_id: thread_id.to_string(),
|
|
items,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/inject_items failed during TUI side conversation setup")
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn turn_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
items: Vec<UserInput>,
|
|
cwd: PathBuf,
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
|
|
active_permission_profile: Option<ActivePermissionProfile>,
|
|
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 permissions =
|
|
turn_permissions_selection(active_permission_profile, self.thread_params_mode());
|
|
self.client
|
|
.request_typed(ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: items,
|
|
responsesapi_client_metadata: None,
|
|
environments: None,
|
|
cwd: Some(cwd),
|
|
workspace_roots: None,
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(approvals_reviewer.into()),
|
|
sandbox_policy: None,
|
|
permissions,
|
|
model: Some(model),
|
|
service_tier,
|
|
effort,
|
|
summary,
|
|
personality,
|
|
output_schema,
|
|
collaboration_mode,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn turn_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: TurnInterruptResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/interrupt failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn startup_interrupt(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
self.turn_interrupt(thread_id, String::new()).await
|
|
}
|
|
|
|
pub(crate) async fn turn_steer(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
items: Vec<UserInput>,
|
|
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::TurnSteer {
|
|
request_id,
|
|
params: TurnSteerParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: items,
|
|
responsesapi_client_metadata: None,
|
|
expected_turn_id: turn_id,
|
|
},
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn thread_set_name(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
name: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadSetNameResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadSetName {
|
|
request_id,
|
|
params: ThreadSetNameParams {
|
|
thread_id: thread_id.to_string(),
|
|
name,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/name/set failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_memory_mode_set(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
mode: ThreadMemoryMode,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadMemoryModeSetResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadMemoryModeSet {
|
|
request_id,
|
|
params: ThreadMemoryModeSetParams {
|
|
thread_id: thread_id.to_string(),
|
|
mode,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/memoryMode/set failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn memory_reset(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: MemoryResetResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::MemoryReset {
|
|
request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("memory/reset failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_get(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<ThreadGoalGetResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalGet {
|
|
request_id,
|
|
params: ThreadGoalGetParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/get failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_set(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
objective: Option<String>,
|
|
status: Option<ThreadGoalStatus>,
|
|
token_budget: Option<Option<i64>>,
|
|
) -> Result<ThreadGoalSetResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalSet {
|
|
request_id,
|
|
params: ThreadGoalSetParams {
|
|
thread_id: thread_id.to_string(),
|
|
objective,
|
|
status,
|
|
token_budget,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/set failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_clear(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<ThreadGoalClearResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalClear {
|
|
request_id,
|
|
params: ThreadGoalClearParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/clear failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn logout_account(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: LogoutAccountResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::LogoutAccount {
|
|
request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("account/logout failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadUnsubscribeResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadUnsubscribe {
|
|
request_id,
|
|
params: ThreadUnsubscribeParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/unsubscribe failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_compact_start(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadCompactStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadCompactStart {
|
|
request_id,
|
|
params: ThreadCompactStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/compact/start failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_shell_command(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
command: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadShellCommandResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadShellCommand {
|
|
request_id,
|
|
params: ThreadShellCommandParams {
|
|
thread_id: thread_id.to_string(),
|
|
command,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/shellCommand failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_approve_guardian_denied_action(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
event: &GuardianAssessmentEvent,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadApproveGuardianDeniedActionResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadApproveGuardianDeniedAction {
|
|
request_id,
|
|
params: ThreadApproveGuardianDeniedActionParams {
|
|
thread_id: thread_id.to_string(),
|
|
event: serde_json::to_value(event)
|
|
.wrap_err("failed to serialize Auto Review denial event")?,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/approveGuardianDeniedAction failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_background_terminals_clean(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadBackgroundTerminalsCleanResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadBackgroundTerminalsClean {
|
|
request_id,
|
|
params: ThreadBackgroundTerminalsCleanParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/backgroundTerminals/clean failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_rollback(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
num_turns: u32,
|
|
) -> Result<ThreadRollbackResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadRollback {
|
|
request_id,
|
|
params: ThreadRollbackParams {
|
|
thread_id: thread_id.to_string(),
|
|
num_turns,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/rollback failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn review_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
target: ReviewTarget,
|
|
) -> Result<ReviewStartResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ReviewStart {
|
|
request_id,
|
|
params: ReviewStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
target,
|
|
delivery: Some(ReviewDelivery::Inline),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("review/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn skills_list(
|
|
&mut self,
|
|
params: SkillsListParams,
|
|
) -> Result<SkillsListResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::SkillsList { request_id, params })
|
|
.await
|
|
.wrap_err("skills/list failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn reload_user_config(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ConfigWriteResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ConfigBatchWrite {
|
|
request_id,
|
|
params: ConfigBatchWriteParams {
|
|
edits: Vec::new(),
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("config/batchWrite failed while reloading user config in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
transport: Option<ThreadRealtimeStartTransport>,
|
|
voice: Option<serde_json::Value>,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let params = thread_realtime_start_params(thread_id, transport, voice)?;
|
|
let _: ThreadRealtimeStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStart { request_id, params })
|
|
.await
|
|
.wrap_err("thread/realtime/start failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_audio(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
frame: ThreadRealtimeAudioChunk,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeAppendAudioResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeAppendAudio {
|
|
request_id,
|
|
params: ThreadRealtimeAppendAudioParams {
|
|
thread_id: thread_id.to_string(),
|
|
audio: frame,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/appendAudio failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeStopResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStop {
|
|
request_id,
|
|
params: ThreadRealtimeStopParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/stop failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn reject_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
error: JSONRPCErrorError,
|
|
) -> std::io::Result<()> {
|
|
self.client.reject_server_request(request_id, error).await
|
|
}
|
|
|
|
pub(crate) async fn resolve_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
result: serde_json::Value,
|
|
) -> std::io::Result<()> {
|
|
self.client.resolve_server_request(request_id, result).await
|
|
}
|
|
|
|
pub(crate) async fn shutdown(self) -> std::io::Result<()> {
|
|
self.client.shutdown().await
|
|
}
|
|
|
|
pub(crate) fn request_handle(&self) -> AppServerRequestHandle {
|
|
self.client.request_handle()
|
|
}
|
|
|
|
fn next_request_id(&mut self) -> RequestId {
|
|
let request_id = self.next_request_id;
|
|
self.next_request_id += 1;
|
|
RequestId::Integer(request_id)
|
|
}
|
|
}
|
|
|
|
fn thread_realtime_start_params(
|
|
thread_id: ThreadId,
|
|
transport: Option<ThreadRealtimeStartTransport>,
|
|
voice: Option<serde_json::Value>,
|
|
) -> Result<ThreadRealtimeStartParams> {
|
|
let mut value = serde_json::Map::new();
|
|
value.insert(
|
|
"threadId".to_string(),
|
|
serde_json::Value::String(thread_id.to_string()),
|
|
);
|
|
value.insert(
|
|
"outputModality".to_string(),
|
|
serde_json::Value::String("audio".to_string()),
|
|
);
|
|
if let Some(transport) = transport {
|
|
value.insert(
|
|
"transport".to_string(),
|
|
serde_json::to_value(transport).wrap_err("serializing realtime transport")?,
|
|
);
|
|
}
|
|
if let Some(voice) = voice {
|
|
value.insert("voice".to_string(), voice);
|
|
}
|
|
|
|
serde_json::from_value(serde_json::Value::Object(value))
|
|
.wrap_err("mapping TUI realtime start params to app-server params")
|
|
}
|
|
|
|
pub(crate) fn status_account_display_from_auth_mode(
|
|
auth_mode: Option<AuthMode>,
|
|
plan_type: Option<codex_protocol::account::PlanType>,
|
|
) -> Option<StatusAccountDisplay> {
|
|
match auth_mode {
|
|
Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey),
|
|
Some(AuthMode::Chatgpt)
|
|
| Some(AuthMode::ChatgptAuthTokens)
|
|
| Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt {
|
|
email: None,
|
|
plan: plan_type.map(plan_type_display_name),
|
|
}),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
|
|
let upgrade = model.upgrade.map(|upgrade_id| {
|
|
let upgrade_info = model.upgrade_info.clone();
|
|
ModelUpgrade {
|
|
id: upgrade_id,
|
|
reasoning_effort_mapping: None,
|
|
migration_config_key: model.model.clone(),
|
|
model_link: upgrade_info
|
|
.as_ref()
|
|
.and_then(|info| info.model_link.clone()),
|
|
upgrade_copy: upgrade_info
|
|
.as_ref()
|
|
.and_then(|info| info.upgrade_copy.clone()),
|
|
migration_markdown: upgrade_info.and_then(|info| info.migration_markdown),
|
|
}
|
|
});
|
|
|
|
ModelPreset {
|
|
id: model.id,
|
|
model: model.model,
|
|
display_name: model.display_name,
|
|
description: model.description,
|
|
default_reasoning_effort: model.default_reasoning_effort,
|
|
supported_reasoning_efforts: model
|
|
.supported_reasoning_efforts
|
|
.into_iter()
|
|
.map(|effort| ReasoningEffortPreset {
|
|
effort: effort.reasoning_effort,
|
|
description: effort.description,
|
|
})
|
|
.collect(),
|
|
supports_personality: model.supports_personality,
|
|
additional_speed_tiers: model.additional_speed_tiers,
|
|
service_tiers: model
|
|
.service_tiers
|
|
.into_iter()
|
|
.map(|service_tier| ModelServiceTier {
|
|
id: service_tier.id,
|
|
name: service_tier.name,
|
|
description: service_tier.description,
|
|
})
|
|
.collect(),
|
|
is_default: model.is_default,
|
|
upgrade,
|
|
show_in_picker: !model.hidden,
|
|
availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux {
|
|
message: nux.message,
|
|
}),
|
|
// `model/list` already returns models filtered for the active client/auth context.
|
|
supported_in_api: true,
|
|
input_modalities: model.input_modalities,
|
|
}
|
|
}
|
|
|
|
fn approvals_reviewer_override_from_config(
|
|
config: &Config,
|
|
) -> Option<codex_app_server_protocol::ApprovalsReviewer> {
|
|
Some(config.approvals_reviewer.into())
|
|
}
|
|
|
|
fn config_request_overrides_from_config(
|
|
config: &Config,
|
|
) -> Option<HashMap<String, serde_json::Value>> {
|
|
let mut overrides = HashMap::new();
|
|
let mut insert = |key: &str, value: Option<String>| {
|
|
if let Some(value) = value {
|
|
overrides.insert(key.to_string(), serde_json::Value::String(value));
|
|
}
|
|
};
|
|
insert("profile", config.active_profile.clone());
|
|
insert(
|
|
"model_reasoning_effort",
|
|
config
|
|
.model_reasoning_effort
|
|
.map(|effort| effort.to_string()),
|
|
);
|
|
insert(
|
|
"model_reasoning_summary",
|
|
config
|
|
.model_reasoning_summary
|
|
.map(|summary| summary.to_string()),
|
|
);
|
|
insert(
|
|
"model_verbosity",
|
|
config
|
|
.model_verbosity
|
|
.map(|verbosity| verbosity.to_string()),
|
|
);
|
|
insert(
|
|
"personality",
|
|
config
|
|
.personality
|
|
.map(|personality| personality.to_string()),
|
|
);
|
|
insert(
|
|
"web_search",
|
|
Some(config.web_search_mode.value().to_string()),
|
|
);
|
|
Some(overrides)
|
|
}
|
|
|
|
fn service_tier_override_from_config(config: &Config) -> Option<Option<String>> {
|
|
config
|
|
.service_tier
|
|
.clone()
|
|
.map(Some)
|
|
.or_else(|| (config.notices.fast_default_opt_out == Some(true)).then_some(None))
|
|
}
|
|
|
|
fn sandbox_mode_from_permission_profile(
|
|
permission_profile: &PermissionProfile,
|
|
cwd: &std::path::Path,
|
|
) -> Option<codex_app_server_protocol::SandboxMode> {
|
|
match permission_profile {
|
|
PermissionProfile::Disabled => {
|
|
Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
|
|
}
|
|
PermissionProfile::External { .. } => None,
|
|
PermissionProfile::Managed { .. } => {
|
|
let file_system_policy = permission_profile.file_system_sandbox_policy();
|
|
if file_system_policy.has_full_disk_write_access() {
|
|
permission_profile
|
|
.network_sandbox_policy()
|
|
.is_enabled()
|
|
.then_some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
|
|
} else if file_system_policy.can_write_path_with_cwd(cwd, cwd) {
|
|
Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
|
|
} else {
|
|
Some(codex_app_server_protocol::SandboxMode::ReadOnly)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn permissions_selection_from_active_profile(
|
|
active: ActivePermissionProfile,
|
|
) -> PermissionProfileSelectionParams {
|
|
PermissionProfileSelectionParams { id: active.id }
|
|
}
|
|
|
|
fn turn_permissions_selection(
|
|
active_permission_profile: Option<ActivePermissionProfile>,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Option<PermissionProfileSelectionParams> {
|
|
if matches!(thread_params_mode, ThreadParamsMode::Remote) {
|
|
return None;
|
|
}
|
|
|
|
active_permission_profile.map(permissions_selection_from_active_profile)
|
|
}
|
|
|
|
fn permissions_selection_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Option<PermissionProfileSelectionParams> {
|
|
if matches!(thread_params_mode, ThreadParamsMode::Remote) {
|
|
return None;
|
|
}
|
|
|
|
config
|
|
.permissions
|
|
.active_permission_profile()
|
|
.map(permissions_selection_from_active_profile)
|
|
}
|
|
|
|
fn thread_start_params_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
session_start_source: Option<ThreadStartSource>,
|
|
) -> ThreadStartParams {
|
|
let permissions = permissions_selection_from_config(config, thread_params_mode);
|
|
let sandbox = permissions
|
|
.is_none()
|
|
.then(|| {
|
|
sandbox_mode_from_permission_profile(
|
|
&config.permissions.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),
|
|
workspace_roots: thread_start_workspace_roots_from_config(config, thread_params_mode),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(config),
|
|
sandbox,
|
|
permissions,
|
|
config: config_request_overrides_from_config(config),
|
|
ephemeral: Some(config.ephemeral),
|
|
session_start_source,
|
|
thread_source: Some(ThreadSource::User),
|
|
persist_extended_history: false,
|
|
..ThreadStartParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_resume_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> ThreadResumeParams {
|
|
let permissions = permissions_selection_from_config(&config, thread_params_mode);
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.to_string(),
|
|
model: config.model.clone(),
|
|
model_provider: thread_params_mode.model_provider_from_config(&config),
|
|
service_tier: service_tier_override_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
|
|
workspace_roots: None,
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(&config),
|
|
sandbox: None,
|
|
permissions,
|
|
config: config_request_overrides_from_config(&config),
|
|
persist_extended_history: false,
|
|
..ThreadResumeParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_fork_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> ThreadForkParams {
|
|
let permissions = permissions_selection_from_config(&config, thread_params_mode);
|
|
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),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(&config),
|
|
sandbox: None,
|
|
permissions,
|
|
config: config_request_overrides_from_config(&config),
|
|
base_instructions: config.base_instructions.clone(),
|
|
developer_instructions: config.developer_instructions.clone(),
|
|
ephemeral: config.ephemeral,
|
|
thread_source: Some(ThreadSource::User),
|
|
persist_extended_history: false,
|
|
..ThreadForkParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_start_workspace_roots_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Option<Vec<AbsolutePathBuf>> {
|
|
match thread_params_mode {
|
|
ThreadParamsMode::Embedded => Some(config.workspace_roots.clone()),
|
|
ThreadParamsMode::Remote => None,
|
|
}
|
|
}
|
|
|
|
fn thread_cwd_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> Option<String> {
|
|
match thread_params_mode {
|
|
ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()),
|
|
ThreadParamsMode::Remote => {
|
|
remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn started_thread_from_start_response(
|
|
response: ThreadStartResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_start_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_resume_response(
|
|
response: ThreadResumeResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_resume_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_fork_response(
|
|
response: ThreadForkResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_fork_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_start_response(
|
|
response: &ThreadStartResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = permission_profile_from_thread_response(
|
|
&response.sandbox,
|
|
response.permission_profile.as_ref(),
|
|
response.cwd.as_path(),
|
|
config,
|
|
thread_params_mode,
|
|
);
|
|
thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.forked_from_id.clone(),
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier.clone(),
|
|
response.approval_policy,
|
|
response.approvals_reviewer.to_core(),
|
|
permission_profile,
|
|
response.active_permission_profile.clone().map(Into::into),
|
|
response.cwd.clone(),
|
|
response.instruction_sources.clone(),
|
|
response.reasoning_effort,
|
|
config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_resume_response(
|
|
response: &ThreadResumeResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = permission_profile_from_thread_response(
|
|
&response.sandbox,
|
|
response.permission_profile.as_ref(),
|
|
response.cwd.as_path(),
|
|
config,
|
|
thread_params_mode,
|
|
);
|
|
thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.forked_from_id.clone(),
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier.clone(),
|
|
response.approval_policy,
|
|
response.approvals_reviewer.to_core(),
|
|
permission_profile,
|
|
response.active_permission_profile.clone().map(Into::into),
|
|
response.cwd.clone(),
|
|
response.instruction_sources.clone(),
|
|
response.reasoning_effort,
|
|
config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_fork_response(
|
|
response: &ThreadForkResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = permission_profile_from_thread_response(
|
|
&response.sandbox,
|
|
response.permission_profile.as_ref(),
|
|
response.cwd.as_path(),
|
|
config,
|
|
thread_params_mode,
|
|
);
|
|
thread_session_state_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.forked_from_id.clone(),
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier.clone(),
|
|
response.approval_policy,
|
|
response.approvals_reviewer.to_core(),
|
|
permission_profile,
|
|
response.active_permission_profile.clone().map(Into::into),
|
|
response.cwd.clone(),
|
|
response.instruction_sources.clone(),
|
|
response.reasoning_effort,
|
|
config,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn permission_profile_from_thread_response(
|
|
sandbox: &codex_app_server_protocol::SandboxPolicy,
|
|
permission_profile: Option<&codex_app_server_protocol::PermissionProfile>,
|
|
cwd: &std::path::Path,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> PermissionProfile {
|
|
if let Some(permission_profile) = permission_profile {
|
|
return permission_profile.clone().into();
|
|
}
|
|
match thread_params_mode {
|
|
ThreadParamsMode::Embedded => config.permissions.permission_profile(),
|
|
ThreadParamsMode::Remote => {
|
|
PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "session mapping keeps explicit fields"
|
|
)]
|
|
async fn thread_session_state_from_thread_response(
|
|
thread_id: &str,
|
|
forked_from_id: Option<String>,
|
|
thread_name: Option<String>,
|
|
rollout_path: Option<PathBuf>,
|
|
model: String,
|
|
model_provider_id: String,
|
|
service_tier: Option<String>,
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
|
|
permission_profile: PermissionProfile,
|
|
active_permission_profile: Option<ActivePermissionProfile>,
|
|
cwd: AbsolutePathBuf,
|
|
instruction_source_paths: Vec<AbsolutePathBuf>,
|
|
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
|
|
config: &Config,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let thread_id = ThreadId::from_string(thread_id)
|
|
.map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?;
|
|
let forked_from_id = forked_from_id
|
|
.as_deref()
|
|
.map(ThreadId::from_string)
|
|
.transpose()
|
|
.map_err(|err| format!("forked_from_id is invalid: {err}"))?;
|
|
let history_config =
|
|
codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);
|
|
let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await;
|
|
Ok(ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id,
|
|
fork_parent_title: None,
|
|
thread_name,
|
|
model,
|
|
model_provider_id,
|
|
service_tier,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
permission_profile,
|
|
active_permission_profile,
|
|
cwd,
|
|
instruction_source_paths,
|
|
reasoning_effort,
|
|
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 mut snapshots = Vec::new();
|
|
snapshots.push(response.rate_limits);
|
|
if let Some(by_limit_id) = response.rate_limits_by_limit_id {
|
|
snapshots.extend(by_limit_id.into_values());
|
|
}
|
|
snapshots
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::legacy_core::config::ConfigBuilder;
|
|
use crate::legacy_core::config::ConfigOverrides;
|
|
use codex_app_server_protocol::FileSystemAccessMode;
|
|
use codex_app_server_protocol::FileSystemPath;
|
|
use codex_app_server_protocol::FileSystemSandboxEntry;
|
|
use codex_app_server_protocol::FileSystemSpecialPath;
|
|
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
|
|
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
|
|
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::config_types::Verbosity;
|
|
use codex_protocol::config_types::WebSearchMode;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
|
|
async fn build_config(temp_dir: &TempDir) -> Config {
|
|
ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().to_path_buf())
|
|
.build()
|
|
.await
|
|
.expect("config should build")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_params_include_cwd_for_embedded_sessions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().to_path_buf())
|
|
.harness_overrides(ConfigOverrides {
|
|
default_permissions: Some(":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.workspace_roots, Some(config.workspace_roots.clone()));
|
|
assert_eq!(params.sandbox, None);
|
|
assert_eq!(
|
|
params.permissions,
|
|
config
|
|
.permissions
|
|
.active_permission_profile()
|
|
.map(permissions_selection_from_active_profile)
|
|
);
|
|
assert_eq!(params.model_provider, Some(config.model_provider_id));
|
|
assert_eq!(params.thread_source, Some(ThreadSource::User));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_params_can_mark_clear_source() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
|
|
let params = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
Some(ThreadStartSource::Clear),
|
|
);
|
|
|
|
assert_eq!(params.session_start_source, Some(ThreadStartSource::Clear));
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_turn_permissions_use_active_profile_selection() {
|
|
let active_permission_profile = ActivePermissionProfile::new(":workspace");
|
|
let expected_permissions =
|
|
permissions_selection_from_active_profile(active_permission_profile.clone());
|
|
|
|
let permissions =
|
|
turn_permissions_selection(Some(active_permission_profile), ThreadParamsMode::Embedded);
|
|
|
|
assert_eq!(permissions, Some(expected_permissions));
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_turn_permissions_omit_overrides_without_active_profile() {
|
|
let permissions = turn_permissions_selection(
|
|
/*active_permission_profile*/ None,
|
|
ThreadParamsMode::Embedded,
|
|
);
|
|
|
|
assert_eq!(permissions, None);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_turn_permissions_omit_overrides_even_with_active_profile() {
|
|
let permissions = turn_permissions_selection(
|
|
Some(ActivePermissionProfile::new(":read-only")),
|
|
ThreadParamsMode::Remote,
|
|
);
|
|
|
|
assert_eq!(permissions, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
let expected_sandbox = sandbox_mode_from_permission_profile(
|
|
&config.permissions.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,
|
|
);
|
|
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.workspace_roots, None);
|
|
assert_eq!(resume.workspace_roots, None);
|
|
assert_eq!(fork.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, None);
|
|
assert_eq!(start.permissions, None);
|
|
assert_eq!(resume.permissions, None);
|
|
assert_eq!(fork.permissions, None);
|
|
assert_eq!(start.thread_source, Some(ThreadSource::User));
|
|
assert_eq!(fork.thread_source, Some(ThreadSource::User));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let extra_root = test_path_buf("/workspace/cache").abs();
|
|
let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
|
|
network: PermissionProfileNetworkPermissions { enabled: false },
|
|
file_system: PermissionProfileFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path { path: extra_root },
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
}
|
|
.into();
|
|
|
|
assert_eq!(
|
|
sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()),
|
|
Some(codex_app_server_protocol::SandboxMode::ReadOnly)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_mode_projects_cwd_write_for_remote_sessions() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
|
|
network: PermissionProfileNetworkPermissions { enabled: false },
|
|
file_system: PermissionProfileFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
}
|
|
.into();
|
|
|
|
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.permission_profile(),
|
|
config.cwd.as_path(),
|
|
);
|
|
|
|
let start = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
Some(remote_cwd.as_path()),
|
|
/*session_start_source*/ None,
|
|
);
|
|
let resume = thread_resume_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
ThreadParamsMode::Remote,
|
|
Some(remote_cwd.as_path()),
|
|
);
|
|
let fork = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Remote,
|
|
Some(remote_cwd.as_path()),
|
|
);
|
|
|
|
assert_eq!(start.cwd.as_deref(), Some("repo/on/server"));
|
|
assert_eq!(resume.cwd.as_deref(), Some("repo/on/server"));
|
|
assert_eq!(fork.cwd.as_deref(), Some("repo/on/server"));
|
|
assert_eq!(start.workspace_roots, None);
|
|
assert_eq!(resume.workspace_roots, None);
|
|
assert_eq!(fork.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, None);
|
|
assert_eq!(start.permissions, None);
|
|
assert_eq!(resume.permissions, None);
|
|
assert_eq!(fork.permissions, None);
|
|
assert_eq!(start.thread_source, Some(ThreadSource::User));
|
|
assert_eq!(fork.thread_source, Some(ThreadSource::User));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_lifecycle_params_forward_model_reasoning_and_service_tier() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.model_reasoning_effort = Some(ReasoningEffort::High);
|
|
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
|
|
config.model_verbosity = Some(Verbosity::Low);
|
|
config.personality = Some(Personality::Pragmatic);
|
|
config
|
|
.web_search_mode
|
|
.set(WebSearchMode::Disabled)
|
|
.expect("test web search mode should be allowed");
|
|
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
|
let thread_id = ThreadId::new();
|
|
|
|
let start = thread_start_params_from_config(
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
/*session_start_source*/ None,
|
|
);
|
|
let resume = thread_resume_params_from_config(
|
|
config.clone(),
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
let fork = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
|
|
let expected_service_tier = Some(Some(ServiceTier::Fast.request_value().to_string()));
|
|
assert_eq!(start.service_tier, expected_service_tier);
|
|
assert_eq!(resume.service_tier, expected_service_tier);
|
|
assert_eq!(fork.service_tier, expected_service_tier);
|
|
let string = |value: &str| serde_json::Value::String(value.to_string());
|
|
let expected_config = HashMap::from([
|
|
("model_reasoning_effort".to_string(), string("high")),
|
|
("model_reasoning_summary".to_string(), string("detailed")),
|
|
("model_verbosity".to_string(), string("low")),
|
|
("personality".to_string(), string("pragmatic")),
|
|
("web_search".to_string(), string("disabled")),
|
|
]);
|
|
assert_eq!(start.config, Some(expected_config.clone()));
|
|
assert_eq!(resume.config, Some(expected_config.clone()));
|
|
assert_eq!(fork.config, Some(expected_config));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_request_overrides_preserve_implicit_personality_default() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.personality = None;
|
|
|
|
let implicit_overrides =
|
|
config_request_overrides_from_config(&config).expect("config overrides");
|
|
|
|
assert!(!implicit_overrides.contains_key("personality"));
|
|
|
|
config.personality = Some(Personality::None);
|
|
let explicit_overrides =
|
|
config_request_overrides_from_config(&config).expect("config overrides");
|
|
|
|
assert_eq!(
|
|
explicit_overrides.get("personality"),
|
|
Some(&serde_json::Value::String("none".to_string()))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_fork_params_forward_instruction_overrides() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let mut config = build_config(&temp_dir).await;
|
|
config.base_instructions = Some("Base override.".to_string());
|
|
config.developer_instructions = Some("Developer override.".to_string());
|
|
let thread_id = ThreadId::new();
|
|
|
|
let params = thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
ThreadParamsMode::Embedded,
|
|
/*remote_cwd_override*/ None,
|
|
);
|
|
|
|
assert_eq!(params.base_instructions.as_deref(), Some("Base override."));
|
|
assert_eq!(
|
|
params.developer_instructions.as_deref(),
|
|
Some("Developer override.")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resume_response_restores_turns_from_thread_items() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
let forked_from_id = ThreadId::new();
|
|
let read_only_profile = PermissionProfile::read_only();
|
|
let response = ThreadResumeResponse {
|
|
thread: codex_app_server_protocol::Thread {
|
|
id: thread_id.to_string(),
|
|
session_id: ThreadId::new().to_string(),
|
|
forked_from_id: Some(forked_from_id.to_string()),
|
|
preview: "hello".to_string(),
|
|
ephemeral: false,
|
|
model_provider: "openai".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
status: ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: codex_app_server_protocol::SessionSource::Cli,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: vec![Turn {
|
|
id: "turn-1".to_string(),
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![
|
|
codex_app_server_protocol::ThreadItem::UserMessage {
|
|
id: "user-1".to_string(),
|
|
content: vec![codex_app_server_protocol::UserInput::Text {
|
|
text: "hello from history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
},
|
|
codex_app_server_protocol::ThreadItem::AgentMessage {
|
|
id: "assistant-1".to_string(),
|
|
text: "assistant reply".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}],
|
|
},
|
|
model: "gpt-5.4".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
workspace_roots: Vec::new(),
|
|
instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()],
|
|
approval_policy: codex_app_server_protocol::AskForApproval::Never,
|
|
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
|
|
sandbox: read_only_profile
|
|
.to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path())
|
|
.expect("read-only profile must be legacy-compatible")
|
|
.into(),
|
|
permission_profile: Some(read_only_profile.clone().into()),
|
|
active_permission_profile: None,
|
|
reasoning_effort: None,
|
|
};
|
|
|
|
let started = started_thread_from_resume_response(
|
|
response.clone(),
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
)
|
|
.await
|
|
.expect("resume response should map");
|
|
assert_eq!(started.session.forked_from_id, Some(forked_from_id));
|
|
assert_eq!(
|
|
started.session.instruction_source_paths,
|
|
response.instruction_sources
|
|
);
|
|
assert_eq!(started.session.permission_profile, read_only_profile);
|
|
assert_eq!(started.turns.len(), 1);
|
|
assert_eq!(started.turns[0], response.thread.turns[0]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_thread_response_prefers_permission_profile_over_legacy_sandbox() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let cwd = test_path_buf("/tmp/project").abs();
|
|
let fallback_sandbox = PermissionProfile::read_only()
|
|
.to_legacy_sandbox_policy(cwd.as_path())
|
|
.expect("read-only profile must be legacy-compatible")
|
|
.into();
|
|
let response_profile = AppServerPermissionProfile::Managed {
|
|
file_system: PermissionProfileFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::ProjectRoots {
|
|
subpath: Some(".env".into()),
|
|
},
|
|
},
|
|
access: FileSystemAccessMode::None,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
network: PermissionProfileNetworkPermissions { enabled: false },
|
|
};
|
|
let split_profile: PermissionProfile = response_profile.clone().into();
|
|
|
|
assert_eq!(
|
|
permission_profile_from_thread_response(
|
|
&fallback_sandbox,
|
|
Some(&response_profile),
|
|
cwd.as_path(),
|
|
&config,
|
|
ThreadParamsMode::Remote,
|
|
),
|
|
split_profile
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn embedded_thread_response_prefers_permission_profile_when_present() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let cwd = test_path_buf("/tmp/project").abs();
|
|
let response_profile = PermissionProfile::read_only().into();
|
|
|
|
assert_eq!(
|
|
permission_profile_from_thread_response(
|
|
&codex_app_server_protocol::SandboxPolicy::DangerFullAccess,
|
|
Some(&response_profile),
|
|
cwd.as_path(),
|
|
&config,
|
|
ThreadParamsMode::Embedded,
|
|
),
|
|
PermissionProfile::read_only()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_populates_history_metadata() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
|
|
let history_config =
|
|
codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);
|
|
|
|
codex_message_history::append_entry("older", &thread_id, &history_config)
|
|
.await
|
|
.expect("history append should succeed");
|
|
codex_message_history::append_entry("newer", &thread_id, &history_config)
|
|
.await
|
|
.expect("history append should succeed");
|
|
|
|
let session = thread_session_state_from_thread_response(
|
|
&thread_id.to_string(),
|
|
/*forked_from_id*/ None,
|
|
Some("restore".to_string()),
|
|
/*rollout_path*/ None,
|
|
"gpt-5.4".to_string(),
|
|
"openai".to_string(),
|
|
/*service_tier*/ None,
|
|
AskForApproval::Never,
|
|
codex_protocol::config_types::ApprovalsReviewer::User,
|
|
PermissionProfile::read_only(),
|
|
/*active_permission_profile*/ None,
|
|
test_path_buf("/tmp/project").abs(),
|
|
Vec::new(),
|
|
/*reasoning_effort*/ None,
|
|
&config,
|
|
)
|
|
.await
|
|
.expect("session should map");
|
|
|
|
let metadata = session
|
|
.message_history
|
|
.expect("session should include message-history metadata");
|
|
assert_ne!(metadata.log_id, 0);
|
|
assert_eq!(metadata.entry_count, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_preserves_fork_source_thread_id() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
let forked_from_id = ThreadId::new();
|
|
|
|
let session = thread_session_state_from_thread_response(
|
|
&thread_id.to_string(),
|
|
Some(forked_from_id.to_string()),
|
|
Some("restore".to_string()),
|
|
/*rollout_path*/ None,
|
|
"gpt-5.4".to_string(),
|
|
"openai".to_string(),
|
|
/*service_tier*/ None,
|
|
AskForApproval::Never,
|
|
codex_protocol::config_types::ApprovalsReviewer::User,
|
|
PermissionProfile::read_only(),
|
|
/*active_permission_profile*/ None,
|
|
test_path_buf("/tmp/project").abs(),
|
|
Vec::new(),
|
|
/*reasoning_effort*/ None,
|
|
&config,
|
|
)
|
|
.await
|
|
.expect("session should map");
|
|
|
|
assert_eq!(session.forked_from_id, Some(forked_from_id));
|
|
}
|
|
|
|
#[test]
|
|
fn status_account_display_from_auth_mode_uses_remapped_plan_labels() {
|
|
let business = status_account_display_from_auth_mode(
|
|
Some(AuthMode::Chatgpt),
|
|
Some(codex_protocol::account::PlanType::EnterpriseCbpUsageBased),
|
|
);
|
|
assert!(matches!(
|
|
business,
|
|
Some(StatusAccountDisplay::ChatGpt {
|
|
email: None,
|
|
plan: Some(ref plan),
|
|
}) if plan == "Enterprise"
|
|
));
|
|
|
|
let team = status_account_display_from_auth_mode(
|
|
Some(AuthMode::Chatgpt),
|
|
Some(codex_protocol::account::PlanType::SelfServeBusinessUsageBased),
|
|
);
|
|
assert!(matches!(
|
|
team,
|
|
Some(StatusAccountDisplay::ChatGpt {
|
|
email: None,
|
|
plan: Some(ref plan),
|
|
}) if plan == "Business"
|
|
));
|
|
}
|
|
}
|