mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Large rollouts are no good. This updates the TUI to behave the same as the Codex App, which is also turning it off.
2026 lines
73 KiB
Rust
2026 lines
73 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;
|
|
#[cfg(test)]
|
|
use crate::legacy_core::append_message_history_entry;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::legacy_core::message_history_metadata;
|
|
use crate::permission_compat::legacy_compatible_permission_profile;
|
|
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::PermissionProfileModificationParams;
|
|
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::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::ActivePermissionProfileModification;
|
|
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::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;
|
|
|
|
/// 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
|
|
.wrap_err("model/list failed during TUI bootstrap")?;
|
|
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
|
|
.wrap_err("account/read failed during TUI bootstrap")
|
|
}
|
|
|
|
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
|
|
.wrap_err("thread/start failed during TUI bootstrap")?;
|
|
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
|
|
.wrap_err("thread/resume failed during TUI bootstrap")?;
|
|
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
|
|
.wrap_err("thread/fork failed during TUI bootstrap")?;
|
|
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,
|
|
permission_profile: PermissionProfile,
|
|
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<codex_protocol::config_types::ServiceTier>>,
|
|
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(
|
|
&permission_profile,
|
|
active_permission_profile,
|
|
cwd.as_path(),
|
|
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),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(approvals_reviewer.into()),
|
|
sandbox_policy,
|
|
permissions,
|
|
model: Some(model),
|
|
service_tier,
|
|
effort,
|
|
summary,
|
|
personality,
|
|
output_schema,
|
|
collaboration_mode,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn turn_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: TurnInterruptResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/interrupt failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn startup_interrupt(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
self.turn_interrupt(thread_id, String::new()).await
|
|
}
|
|
|
|
pub(crate) async fn turn_steer(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
items: Vec<UserInput>,
|
|
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::TurnSteer {
|
|
request_id,
|
|
params: TurnSteerParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: items,
|
|
responsesapi_client_metadata: None,
|
|
expected_turn_id: turn_id,
|
|
},
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub(crate) async fn thread_set_name(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
name: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadSetNameResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadSetName {
|
|
request_id,
|
|
params: ThreadSetNameParams {
|
|
thread_id: thread_id.to_string(),
|
|
name,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/name/set failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_memory_mode_set(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
mode: ThreadMemoryMode,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadMemoryModeSetResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadMemoryModeSet {
|
|
request_id,
|
|
params: ThreadMemoryModeSetParams {
|
|
thread_id: thread_id.to_string(),
|
|
mode,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/memoryMode/set failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn memory_reset(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: MemoryResetResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::MemoryReset {
|
|
request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("memory/reset failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_get(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<ThreadGoalGetResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalGet {
|
|
request_id,
|
|
params: ThreadGoalGetParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/get failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_set(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
objective: Option<String>,
|
|
status: Option<ThreadGoalStatus>,
|
|
token_budget: Option<Option<i64>>,
|
|
) -> Result<ThreadGoalSetResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalSet {
|
|
request_id,
|
|
params: ThreadGoalSetParams {
|
|
thread_id: thread_id.to_string(),
|
|
objective,
|
|
status,
|
|
token_budget,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/set failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn thread_goal_clear(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<ThreadGoalClearResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadGoalClear {
|
|
request_id,
|
|
params: ThreadGoalClearParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/goal/clear failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn logout_account(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: LogoutAccountResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::LogoutAccount {
|
|
request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("account/logout failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadUnsubscribeResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadUnsubscribe {
|
|
request_id,
|
|
params: ThreadUnsubscribeParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/unsubscribe failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_compact_start(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadCompactStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadCompactStart {
|
|
request_id,
|
|
params: ThreadCompactStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/compact/start failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_shell_command(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
command: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadShellCommandResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadShellCommand {
|
|
request_id,
|
|
params: ThreadShellCommandParams {
|
|
thread_id: thread_id.to_string(),
|
|
command,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/shellCommand failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_approve_guardian_denied_action(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
event: &GuardianAssessmentEvent,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadApproveGuardianDeniedActionResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadApproveGuardianDeniedAction {
|
|
request_id,
|
|
params: ThreadApproveGuardianDeniedActionParams {
|
|
thread_id: thread_id.to_string(),
|
|
event: serde_json::to_value(event)
|
|
.wrap_err("failed to serialize Auto Review denial event")?,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/approveGuardianDeniedAction failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_background_terminals_clean(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadBackgroundTerminalsCleanResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadBackgroundTerminalsClean {
|
|
request_id,
|
|
params: ThreadBackgroundTerminalsCleanParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/backgroundTerminals/clean failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_rollback(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
num_turns: u32,
|
|
) -> Result<ThreadRollbackResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ThreadRollback {
|
|
request_id,
|
|
params: ThreadRollbackParams {
|
|
thread_id: thread_id.to_string(),
|
|
num_turns,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/rollback failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn review_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
target: ReviewTarget,
|
|
) -> Result<ReviewStartResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::ReviewStart {
|
|
request_id,
|
|
params: ReviewStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
target,
|
|
delivery: Some(ReviewDelivery::Inline),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("review/start failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn skills_list(
|
|
&mut self,
|
|
params: SkillsListParams,
|
|
) -> Result<SkillsListResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::SkillsList { request_id, params })
|
|
.await
|
|
.wrap_err("skills/list failed in TUI")
|
|
}
|
|
|
|
pub(crate) async fn reload_user_config(&mut self) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ConfigWriteResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ConfigBatchWrite {
|
|
request_id,
|
|
params: ConfigBatchWriteParams {
|
|
edits: Vec::new(),
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: true,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("config/batchWrite failed while reloading user config in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
transport: Option<ThreadRealtimeStartTransport>,
|
|
voice: Option<serde_json::Value>,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let params = thread_realtime_start_params(thread_id, transport, voice)?;
|
|
let _: ThreadRealtimeStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStart { request_id, params })
|
|
.await
|
|
.wrap_err("thread/realtime/start failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_audio(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
frame: ThreadRealtimeAudioChunk,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeAppendAudioResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeAppendAudio {
|
|
request_id,
|
|
params: ThreadRealtimeAppendAudioParams {
|
|
thread_id: thread_id.to_string(),
|
|
audio: frame,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/appendAudio failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeStopResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStop {
|
|
request_id,
|
|
params: ThreadRealtimeStopParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/stop failed in TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn reject_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
error: JSONRPCErrorError,
|
|
) -> std::io::Result<()> {
|
|
self.client.reject_server_request(request_id, error).await
|
|
}
|
|
|
|
pub(crate) async fn resolve_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
result: serde_json::Value,
|
|
) -> std::io::Result<()> {
|
|
self.client.resolve_server_request(request_id, result).await
|
|
}
|
|
|
|
pub(crate) async fn shutdown(self) -> std::io::Result<()> {
|
|
self.client.shutdown().await
|
|
}
|
|
|
|
pub(crate) fn request_handle(&self) -> AppServerRequestHandle {
|
|
self.client.request_handle()
|
|
}
|
|
|
|
fn next_request_id(&mut self) -> RequestId {
|
|
let request_id = self.next_request_id;
|
|
self.next_request_id += 1;
|
|
RequestId::Integer(request_id)
|
|
}
|
|
}
|
|
|
|
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,
|
|
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>> {
|
|
config.active_profile.as_ref().map(|profile| {
|
|
HashMap::from([(
|
|
"profile".to_string(),
|
|
serde_json::Value::String(profile.clone()),
|
|
)])
|
|
})
|
|
}
|
|
|
|
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 {
|
|
let modifications = active
|
|
.modifications
|
|
.into_iter()
|
|
.map(|modification| match modification {
|
|
ActivePermissionProfileModification::AdditionalWritableRoot { path } => {
|
|
PermissionProfileModificationParams::AdditionalWritableRoot { path }
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
PermissionProfileSelectionParams::Profile {
|
|
id: active.id,
|
|
modifications: (!modifications.is_empty()).then_some(modifications),
|
|
}
|
|
}
|
|
|
|
fn turn_permissions_overrides(
|
|
permission_profile: &PermissionProfile,
|
|
active_permission_profile: Option<ActivePermissionProfile>,
|
|
cwd: &std::path::Path,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> (
|
|
Option<codex_app_server_protocol::SandboxPolicy>,
|
|
Option<PermissionProfileSelectionParams>,
|
|
) {
|
|
let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) {
|
|
active_permission_profile.map(permissions_selection_from_active_profile)
|
|
} else {
|
|
None
|
|
};
|
|
let sandbox_policy = (matches!(thread_params_mode, ThreadParamsMode::Remote)
|
|
|| permissions.is_none())
|
|
.then(|| {
|
|
let legacy_profile = legacy_compatible_permission_profile(permission_profile, cwd);
|
|
let policy = legacy_profile
|
|
.to_legacy_sandbox_policy(cwd)
|
|
.unwrap_or_else(|err| {
|
|
unreachable!("legacy-compatible permissions must project to legacy policy: {err}")
|
|
});
|
|
policy.into()
|
|
});
|
|
(sandbox_policy, permissions)
|
|
}
|
|
|
|
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),
|
|
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,
|
|
permissions,
|
|
config: config_request_overrides_from_config(config),
|
|
ephemeral: Some(config.ephemeral),
|
|
session_start_source,
|
|
persist_extended_history: false,
|
|
..ThreadStartParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_resume_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> ThreadResumeParams {
|
|
let permissions = permissions_selection_from_config(&config, thread_params_mode);
|
|
let sandbox = permissions
|
|
.is_none()
|
|
.then(|| {
|
|
sandbox_mode_from_permission_profile(
|
|
&config.permissions.permission_profile(),
|
|
config.cwd.as_path(),
|
|
)
|
|
})
|
|
.flatten();
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.to_string(),
|
|
model: config.model.clone(),
|
|
model_provider: thread_params_mode.model_provider_from_config(&config),
|
|
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,
|
|
permissions,
|
|
config: config_request_overrides_from_config(&config),
|
|
persist_extended_history: false,
|
|
..ThreadResumeParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_fork_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> ThreadForkParams {
|
|
let permissions = permissions_selection_from_config(&config, thread_params_mode);
|
|
let sandbox = permissions
|
|
.is_none()
|
|
.then(|| {
|
|
sandbox_mode_from_permission_profile(
|
|
&config.permissions.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),
|
|
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,
|
|
permissions,
|
|
config: config_request_overrides_from_config(&config),
|
|
base_instructions: config.base_instructions.clone(),
|
|
developer_instructions: config.developer_instructions.clone(),
|
|
ephemeral: config.ephemeral,
|
|
persist_extended_history: false,
|
|
..ThreadForkParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_cwd_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
remote_cwd_override: Option<&std::path::Path>,
|
|
) -> Option<String> {
|
|
match thread_params_mode {
|
|
ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()),
|
|
ThreadParamsMode::Remote => {
|
|
remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn started_thread_from_start_response(
|
|
response: ThreadStartResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_start_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_resume_response(
|
|
response: ThreadResumeResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_resume_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn started_thread_from_fork_response(
|
|
response: ThreadForkResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session =
|
|
thread_session_state_from_thread_fork_response(&response, config, thread_params_mode)
|
|
.await
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session,
|
|
turns: response.thread.turns,
|
|
})
|
|
}
|
|
|
|
async fn thread_session_state_from_thread_start_response(
|
|
response: &ThreadStartResponse,
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> Result<ThreadSessionState, String> {
|
|
let permission_profile = 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,
|
|
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,
|
|
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,
|
|
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<codex_protocol::config_types::ServiceTier>,
|
|
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_log_id, history_entry_count) = message_history_metadata(config).await;
|
|
let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX);
|
|
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,
|
|
history_log_id,
|
|
history_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_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.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));
|
|
}
|
|
|
|
#[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(":workspace");
|
|
let expected_permissions =
|
|
permissions_selection_from_active_profile(active_permission_profile.clone());
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
&PermissionProfile::workspace_write(),
|
|
Some(active_permission_profile),
|
|
cwd.as_path(),
|
|
ThreadParamsMode::Embedded,
|
|
);
|
|
|
|
assert_eq!(sandbox_policy, None);
|
|
assert_eq!(permissions, Some(expected_permissions));
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
&PermissionProfile::read_only(),
|
|
/*active_permission_profile*/ None,
|
|
cwd.as_path(),
|
|
ThreadParamsMode::Embedded,
|
|
);
|
|
|
|
assert_eq!(
|
|
sandbox_policy,
|
|
Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
|
network_access: false
|
|
})
|
|
);
|
|
assert_eq!(permissions, None);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_turn_permissions_use_sandbox_even_with_active_profile() {
|
|
let cwd = test_path_buf("/workspace/project").abs();
|
|
|
|
let (sandbox_policy, permissions) = turn_permissions_overrides(
|
|
&PermissionProfile::read_only(),
|
|
Some(ActivePermissionProfile::new(":read-only")),
|
|
cwd.as_path(),
|
|
ThreadParamsMode::Remote,
|
|
);
|
|
|
|
assert_eq!(
|
|
sandbox_policy,
|
|
Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
|
|
network_access: false
|
|
})
|
|
);
|
|
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.model_provider, None);
|
|
assert_eq!(resume.model_provider, None);
|
|
assert_eq!(fork.model_provider, None);
|
|
assert_eq!(start.sandbox, expected_sandbox);
|
|
assert_eq!(resume.sandbox, expected_sandbox);
|
|
assert_eq!(fork.sandbox, expected_sandbox);
|
|
assert_eq!(start.permissions, None);
|
|
assert_eq!(resume.permissions, None);
|
|
assert_eq!(fork.permissions, None);
|
|
}
|
|
|
|
#[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.model_provider, None);
|
|
assert_eq!(resume.model_provider, None);
|
|
assert_eq!(fork.model_provider, None);
|
|
assert_eq!(start.sandbox, expected_sandbox);
|
|
assert_eq!(resume.sandbox, expected_sandbox);
|
|
assert_eq!(fork.sandbox, expected_sandbox);
|
|
assert_eq!(start.permissions, None);
|
|
assert_eq!(resume.permissions, None);
|
|
assert_eq!(fork.permissions, None);
|
|
}
|
|
|
|
#[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(),
|
|
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,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: vec![Turn {
|
|
id: "turn-1".to_string(),
|
|
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(),
|
|
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();
|
|
|
|
append_message_history_entry("older", &thread_id, &config)
|
|
.await
|
|
.expect("history append should succeed");
|
|
append_message_history_entry("newer", &thread_id, &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");
|
|
|
|
assert_ne!(session.history_log_id, 0);
|
|
assert_eq!(session.history_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"
|
|
));
|
|
}
|
|
}
|