Files
codex/codex-rs/tui/src/app_server_session.rs
T
Michael BolinandGitHub c25d905f61 permissions: support workspace roots in profiles (#22610)
## Why

This is the configuration/model half of the alternative permissions
migration we discussed as a comparison point for
[#22401](https://github.com/openai/codex/pull/22401) and
[#22402](https://github.com/openai/codex/pull/22402).

The old `workspace-write` model mixes three concerns that we want to
keep separate:
- reusable profile rules that should stay immutable once selected
- user/runtime workspace roots from `cwd`, `--add-dir`, and legacy
workspace-write config
- internal Codex writable roots such as memories, which should not be
shown as user workspace roots

This PR gives permission profiles first-class `workspace_roots` so users
can opt multiple repositories into the same `:workspace_roots` rules
without using broad absolute-path write grants. It also starts
separating the raw selected profile from the effective runtime profile
by making `Permissions` expose explicit accessors instead of public
mutable fields.

A representative `config.toml` looks like this:

```toml
default_permissions = "dev"

[permissions.dev.workspace_roots]
"~/code/openai" = true
"~/code/developers-website" = true

[permissions.dev.filesystem.":workspace_roots"]
"." = "write"
".codex" = "read"
".git" = "read"
".vscode" = "read"
```

If Codex starts in `~/code/codex` with that profile selected, the
effective workspace-root set becomes:
- `~/code/codex` from the runtime `cwd`
- `~/code/openai` from the profile
- `~/code/developers-website` from the profile

The `:workspace_roots` rules are materialized across each root, so
`.git`, `.codex`, and `.vscode` stay scoped the same way everywhere.
Runtime additions such as `--add-dir` can still layer on later stack
entries without mutating the selected profile.

## Stack Shape

This PR intentionally stops before the profile-identity cleanup in
[#22683](https://github.com/openai/codex/pull/22683) so the base review
stays focused on config loading, workspace-root materialization, and
compatibility with legacy `workspace-write`.

The representation in this PR is therefore transitional: `Permissions`
carries enough state to distinguish the raw constrained profile from the
effective runtime profile, and there are still call sites that must keep
the active profile identity and constrained profile value in sync. The
follow-up PR replaces that with a single resolved profile state
(`ResolvedPermissionProfile` / `PermissionProfileState`) that keeps the
profile id, immutable `PermissionProfile`, and profile-declared
workspace roots together. That follow-up removes APIs such as
`set_constrained_permission_profile_with_active_profile()` where
separate arguments could drift out of sync.

Downstream PRs then build on this base to switch app-server turn updates
to profile ids plus runtime workspace roots and to finish the
user-visible summary behavior. Reviewers should judge this PR as the
workspace-roots foundation, not as the final in-memory shape of selected
permission profiles.

## Review Guide

Suggested review order:

1. Start with `codex-rs/core/src/config/mod.rs`.
This is the main shape change in the base slice. `Permissions` now
stores a private raw `Constrained<PermissionProfile>` plus runtime
`workspace_roots`. Callers use `permission_profile()` when they need the
raw constrained value and `effective_permission_profile()` when they
need a materialized runtime profile. As noted above,
[#22683](https://github.com/openai/codex/pull/22683) replaces this
transitional shape with a resolved profile state that keeps identity and
profile data together.

2. Review `codex-rs/config/src/permissions_toml.rs` and
`codex-rs/core/src/config/permissions.rs`.
These add `[permissions.<id>.workspace_roots]`, resolve enabled entries
relative to the policy cwd, and keep `:workspace_roots` deny-read glob
patterns symbolic until the actual roots are known.

3. Review `codex-rs/protocol/src/permissions.rs` and
`codex-rs/protocol/src/models.rs`.
These add the policy/profile materialization helpers that expand exact
`:workspace_roots` entries and scoped deny-read globs over every
workspace root. This is also where `ActivePermissionProfileModification`
is removed from the core model.

4. Review the legacy bridge in
`Config::load_from_base_config_with_overrides` and
`Config::set_legacy_sandbox_policy`.
This is where legacy `workspace-write` roots become runtime workspace
roots, while Codex internal writable roots stay internal and do not
appear as user-facing workspace roots.

5. Then skim downstream call sites.
The interesting pattern is raw-vs-effective access: state/proxy/bwrap
paths keep the raw constrained profile, while execution, summaries, and
user-visible status use the effective profile and workspace-root list.

## What Changed

- added `[permissions.<id>.workspace_roots]` to the config model and
schema
- added runtime `workspace_roots` state to `Config`/`Permissions` and
`ConfigOverrides`
- made `Permissions` profile fields private and replaced direct mutation
with accessors/setters
- added `PermissionProfile` and `FileSystemSandboxPolicy` helpers for
materializing `:workspace_roots` exact paths and deny-read globs across
all roots
- moved legacy additional writable roots into runtime workspace-root
state instead of active profile modifications
- removed `ActivePermissionProfileModification` and its app-server
protocol/schema export
- updated sandbox/status summary paths so internal writable roots are
not reported as user workspace roots

## Verification Strategy

The targeted tests cover the behavior at the layers where regressions
are most likely:
- `codex-rs/core/src/config/config_tests.rs` verifies config loading,
legacy workspace-root seeding, effective profile materialization, and
memory-root handling.
- `codex-rs/core/src/config/permissions_tests.rs` verifies profile
`workspace_roots` parsing and `:workspace_roots` scoped/glob
compilation.
- `codex-rs/protocol/src/permissions.rs` unit tests verify exact and
glob materialization over multiple workspace roots.
- `codex-rs/tui/src/status/tests.rs` and
`codex-rs/utils/sandbox-summary/src/sandbox_summary.rs` verify the
user-facing summaries show effective workspace roots and hide internal
writes.

I also ran `cargo check --tests` locally after the latest stack refresh
to catch cross-crate API breakage from the private-field/accessor
changes.







---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22610).
* #22612
* #22611
* #22683
* __->__ #22610
2026-05-14 18:25:23 -07:00

2266 lines
82 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::permission_compat::legacy_compatible_permission_profile;
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::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::ThreadMetadataGitInfoUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::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_metadata_update_branch(
&mut self,
thread_id: ThreadId,
branch: String,
) -> Result<ThreadMetadataUpdateResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadMetadataUpdate {
request_id,
params: ThreadMetadataUpdateParams {
thread_id: thread_id.to_string(),
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some(branch)),
origin_url: None,
}),
},
})
.await
.wrap_err("thread/metadata/update failed while syncing git branch")
}
pub(crate) async fn thread_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>,
workspace_roots: &[AbsolutePathBuf],
model: String,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
summary: Option<codex_protocol::config_types::ReasoningSummary>,
service_tier: Option<Option<String>>,
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
personality: Option<codex_protocol::config_types::Personality>,
output_schema: Option<serde_json::Value>,
) -> Result<TurnStartResponse> {
let request_id = self.next_request_id();
let (sandbox_policy, permissions) = turn_permissions_overrides(
&permission_profile,
active_permission_profile,
cwd.as_path(),
workspace_roots,
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,
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,
cwd: &std::path::Path,
workspace_roots: &[AbsolutePathBuf],
) -> PermissionProfileSelectionParams {
let modifications = workspace_roots
.iter()
.filter(|root| root.as_path() != cwd)
.cloned()
.map(|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,
workspace_roots: &[AbsolutePathBuf],
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(|active| permissions_selection_from_active_profile(active, cwd, workspace_roots))
} 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(|active| {
permissions_selection_from_active_profile(
active,
config.cwd.as_path(),
config.permissions.user_visible_workspace_roots(),
)
})
}
fn thread_start_params_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
session_start_source: Option<ThreadStartSource>,
) -> ThreadStartParams {
let permissions = permissions_selection_from_config(config, thread_params_mode);
let sandbox = permissions
.is_none()
.then(|| {
sandbox_mode_from_permission_profile(
&config.permissions.effective_permission_profile(),
config.cwd.as_path(),
)
})
.flatten();
ThreadStartParams {
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(config),
service_tier: service_tier_override_from_config(config),
cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override),
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);
let sandbox = permissions
.is_none()
.then(|| {
sandbox_mode_from_permission_profile(
&config.permissions.effective_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),
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,
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.effective_permission_profile(),
config.cwd.as_path(),
)
})
.flatten();
ThreadForkParams {
thread_id: thread_id.to_string(),
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(&config),
service_tier: service_tier_override_from_config(&config),
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
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,
thread_source: Some(ThreadSource::User),
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.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.effective_permission_profile(),
ThreadParamsMode::Remote => {
PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd)
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "session mapping keeps explicit fields"
)]
async fn thread_session_state_from_thread_response(
thread_id: &str,
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::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
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(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
..ConfigOverrides::default()
})
.build()
.await
.expect("config should build");
let params = thread_start_params_from_config(
&config,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string()));
assert_eq!(params.sandbox, None);
assert_eq!(
params.permissions,
config
.permissions
.active_permission_profile()
.map(|active| {
permissions_selection_from_active_profile(
active,
config.cwd.as_path(),
config.permissions.user_visible_workspace_roots(),
)
})
);
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 cwd = test_path_buf("/workspace/project").abs();
let active_permission_profile =
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
let workspace_roots = vec![cwd.clone()];
let expected_permissions = permissions_selection_from_active_profile(
active_permission_profile.clone(),
cwd.as_path(),
&workspace_roots,
);
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::workspace_write(),
Some(active_permission_profile),
cwd.as_path(),
&workspace_roots,
ThreadParamsMode::Embedded,
);
assert_eq!(sandbox_policy, None);
assert_eq!(permissions, Some(expected_permissions));
}
#[test]
fn embedded_turn_permissions_include_extra_workspace_roots_as_modifications() {
let cwd = test_path_buf("/workspace/project").abs();
let extra_root = test_path_buf("/workspace/cache").abs();
let active_permission_profile =
ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
let workspace_roots = vec![cwd.clone(), extra_root.clone()];
let (sandbox_policy, permissions) = turn_permissions_overrides(
&PermissionProfile::workspace_write(),
Some(active_permission_profile),
cwd.as_path(),
&workspace_roots,
ThreadParamsMode::Embedded,
);
assert_eq!(sandbox_policy, None);
assert_eq!(
permissions,
Some(PermissionProfileSelectionParams::Profile {
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
modifications: Some(vec![
PermissionProfileModificationParams::AdditionalWritableRoot {
path: extra_root
}
]),
})
);
}
#[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(),
std::slice::from_ref(&cwd),
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(
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
)),
cwd.as_path(),
std::slice::from_ref(&cwd),
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.effective_permission_profile(),
config.cwd.as_path(),
);
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Remote,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
let resume = thread_resume_params_from_config(
config.clone(),
thread_id,
ThreadParamsMode::Remote,
/*remote_cwd_override*/ None,
);
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);
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.effective_permission_profile(),
config.cwd.as_path(),
);
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
/*session_start_source*/ None,
);
let resume = thread_resume_params_from_config(
config.clone(),
thread_id,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
);
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);
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(),
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"
));
}
}