//! App-server session facade used by the TUI event loop.
//!
//! This module owns the typed JSON-RPC calls needed by the TUI and keeps
//! request/response plumbing out of `App` and `ChatWidget`.

mod fs;

use crate::bottom_pane::FeedbackAudience;
use crate::legacy_core::config::Config;
use crate::permission_compat::legacy_compatible_permission_profile;
use crate::service_tier_resolution;
use crate::session_state::MessageHistoryMetadata;
use crate::session_state::ThreadSessionState;
use crate::status::StatusAccountDisplay;
use crate::status::plan_type_display_name;
use crate::terminal_visualization_instructions::with_terminal_visualization_instructions;
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerPath;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::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::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::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadGoalClearParams;
use codex_app_server_protocol::ThreadGoalClearResponse;
use codex_app_server_protocol::ThreadGoalGetParams;
use codex_app_server_protocol::ThreadGoalGetResponse;
use codex_app_server_protocol::ThreadGoalSetParams;
use codex_app_server_protocol::ThreadGoalSetResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadSettingsUpdateResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartSource;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::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;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use uuid::Uuid;

const JSONRPC_INVALID_REQUEST: i64 = -32600;
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str =
    "A previous Claude Code import is still running. Wait for it to finish before importing again.";
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";

fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
    color_eyre::eyre::eyre!("{context}: {err}")
}

fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool {
    source.code == JSONRPC_METHOD_NOT_FOUND
        || (source.code == JSONRPC_INVALID_REQUEST
            && source.message.contains(THREAD_SETTINGS_UPDATE_METHOD))
}

/// Data collected during the TUI bootstrap phase that the main event loop
/// needs to configure the UI, telemetry, and initial rate-limit prefetch.
///
/// Rate-limit snapshots are intentionally **not** included here; they are
/// fetched asynchronously after bootstrap returns so that the TUI can render
/// its first frame without waiting for the rate-limit round-trip.
pub(crate) struct AppServerBootstrap {
    pub(crate) duration: Duration,
    pub(crate) account_email: Option<String>,
    pub(crate) auth_mode: Option<TelemetryAuthMode>,
    pub(crate) status_account_display: Option<StatusAccountDisplay>,
    pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
    /// Whether the configured model provider needs OpenAI-style auth. Combined
    /// with `has_chatgpt_account` to decide if a startup rate-limit prefetch
    /// should be fired.
    pub(crate) requires_openai_auth: bool,
    pub(crate) default_model: String,
    pub(crate) feedback_audience: FeedbackAudience,
    pub(crate) has_chatgpt_account: bool,
    pub(crate) available_models: Vec<ModelPreset>,
}

pub(crate) struct AppServerSession {
    client: AppServerClient,
    next_request_id: i64,
    remote_cwd_override: Option<PathBuf>,
    thread_params_mode: ThreadParamsMode,
    thread_settings_update_supported: bool,
    default_model: Option<String>,
    available_models: Vec<ModelPreset>,
    external_agent_config_import_completion_pending: AtomicBool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) 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,
        }
    }
}

#[derive(Debug)]
pub(crate) struct AppServerStartedThread {
    pub(crate) session: ThreadSessionState,
    pub(crate) turns: Vec<Turn>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TurnPermissionsOverride {
    /// Leave the app-server thread's sticky permission profile unchanged.
    Preserve,
    /// Select a named or built-in profile by id.
    ActiveProfile(ActivePermissionProfile),
    /// Apply a user-selected legacy/custom permission profile.
    LegacySandbox(PermissionProfile),
}

impl AppServerSession {
    pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self {
        Self {
            client,
            next_request_id: 1,
            remote_cwd_override: None,
            thread_params_mode,
            thread_settings_update_supported: true,
            default_model: None,
            available_models: Vec::new(),
            external_agent_config_import_completion_pending: AtomicBool::new(false),
        }
    }

    pub(crate) fn with_remote_cwd_override(mut self, remote_cwd_override: Option<PathBuf>) -> Self {
        self.remote_cwd_override = remote_cwd_override;
        self
    }

    pub(crate) fn remote_cwd_override(&self) -> Option<&std::path::Path> {
        self.remote_cwd_override.as_deref()
    }

    pub(crate) fn uses_remote_workspace(&self) -> bool {
        matches!(self.thread_params_mode, ThreadParamsMode::Remote)
    }

    pub(crate) fn uses_embedded_app_server(&self) -> bool {
        matches!(&self.client, AppServerClient::InProcess(_))
    }

    pub(crate) fn codex_home_path(
        &self,
        local_codex_home: &AbsolutePathBuf,
    ) -> Option<AppServerPath> {
        self.client.codex_home(local_codex_home)
    }

    pub(crate) fn server_version(&self) -> Option<&str> {
        let AppServerClient::Remote(client) = &self.client else {
            return None;
        };
        client.server_version()
    }

    pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
        let started_at = Instant::now();
        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")?;
        self.default_model = Some(default_model.clone());
        self.available_models = available_models.clone();

        let (
            account_email,
            auth_mode,
            status_account_display,
            plan_type,
            feedback_audience,
            has_chatgpt_account,
        ) = match account.account {
            Some(Account::ApiKey {}) => (
                None,
                Some(TelemetryAuthMode::ApiKey),
                Some(StatusAccountDisplay::ApiKey),
                None,
                FeedbackAudience::External,
                false,
            ),
            Some(Account::Chatgpt { email, plan_type }) => {
                let feedback_audience = if email.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 {
            duration: started_at.elapsed(),
            account_email,
            auth_mode,
            status_account_display,
            plan_type,
            requires_openai_auth: account.requires_openai_auth,
            default_model,
            feedback_audience,
            has_chatgpt_account,
            available_models,
        })
    }

    /// 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 Claude Code import")
    }

    pub(crate) async fn external_agent_config_import(
        &mut self,
        migration_items: Vec<ExternalAgentConfigMigrationItem>,
    ) -> Result<()> {
        // Mark the import active before sending the request so a fast completion notification
        // cannot arrive before the TUI records it.
        if self
            .external_agent_config_import_completion_pending
            .swap(true, Ordering::Relaxed)
        {
            color_eyre::eyre::bail!(EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE);
        }
        let request_id = self.next_request_id();
        let response: Result<ExternalAgentConfigImportResponse> = self
            .client
            .request_typed(ClientRequest::ExternalAgentConfigImport {
                request_id,
                params: ExternalAgentConfigImportParams { migration_items },
            })
            .await
            .wrap_err("externalAgentConfig/import failed during Claude Code import");
        match response {
            Ok(_) => Ok(()),
            Err(err) => {
                self.external_agent_config_import_completion_pending
                    .store(false, Ordering::Relaxed);
                Err(err)
            }
        }
    }

    pub(crate) fn external_agent_config_import_in_progress(&self) -> bool {
        self.external_agent_config_import_completion_pending
            .load(Ordering::Relaxed)
    }

    pub(crate) fn consume_external_agent_config_import_completion(&self) -> bool {
        self.external_agent_config_import_completion_pending
            .swap(false, Ordering::Relaxed)
    }

    pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
        self.client.next_event().await
    }

    #[cfg(test)]
    pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
        self.start_thread_with_session_start_source(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 session_config = self.session_config_with_effective_service_tier(config);
        let response: ThreadStartResponse = self
            .client
            .request_typed(ClientRequest::ThreadStart {
                request_id,
                params: thread_start_params_from_config(
                    &session_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 session_config = self.session_config_with_effective_service_tier(&config);
        let response: ThreadResumeResponse = self
            .client
            .request_typed(ClientRequest::ThreadResume {
                request_id,
                params: thread_resume_params_from_config(
                    session_config,
                    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 session_config = self.session_config_with_effective_service_tier(&config);
        let response: ThreadForkResponse = self
            .client
            .request_typed(ClientRequest::ThreadFork {
                request_id,
                params: thread_fork_params_from_config(
                    session_config,
                    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)
    }

    pub(crate) fn thread_params_mode(&self) -> ThreadParamsMode {
        self.thread_params_mode
    }

    fn session_config_with_effective_service_tier(&self, config: &Config) -> Config {
        let Some(model) = config.model.as_deref().or(self.default_model.as_deref()) else {
            return config.clone();
        };
        let mut session_config = config.clone();
        match service_tier_resolution::service_tier_update_for_core(
            config,
            model,
            &self.available_models,
        ) {
            Some(Some(service_tier)) => {
                session_config.service_tier = Some(service_tier);
                session_config.notices.fast_default_opt_out = None;
            }
            Some(None) => {
                session_config.service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string());
                session_config.notices.fast_default_opt_out = None;
            }
            None => {
                session_config.service_tier = None;
                session_config.notices.fast_default_opt_out = None;
            }
        }
        session_config
    }

    async fn fork_parent_title_from_app_server(
        &mut self,
        forked_from_id: Option<&str>,
    ) -> Option<String> {
        let forked_from_id = forked_from_id?;
        let forked_from_id = match ThreadId::from_string(forked_from_id) {
            Ok(thread_id) => thread_id,
            Err(err) => {
                tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
                return None;
            }
        };

        match self
            .thread_read(forked_from_id, /*include_turns*/ false)
            .await
        {
            Ok(thread) => thread.name,
            Err(err) => {
                tracing::warn!("Failed to read fork parent metadata from app server: {err}");
                None
            }
        }
    }

    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_archive(&mut self, thread_id: ThreadId) -> Result<()> {
        let request_id = self.next_request_id();
        let _: ThreadArchiveResponse = self
            .client
            .request_typed(ClientRequest::ThreadArchive {
                request_id,
                params: ThreadArchiveParams {
                    thread_id: thread_id.to_string(),
                },
            })
            .await
            .wrap_err("failed to archive session")?;
        Ok(())
    }

    pub(crate) async fn thread_delete(&mut self, thread_id: ThreadId) -> Result<()> {
        let request_id = self.next_request_id();
        let _: ThreadDeleteResponse = self
            .client
            .request_typed(ClientRequest::ThreadDelete {
                request_id,
                params: ThreadDeleteParams {
                    thread_id: thread_id.to_string(),
                },
            })
            .await
            .wrap_err("failed to delete session")?;
        Ok(())
    }

    pub(crate) async fn thread_unarchive(&mut self, thread_id: ThreadId) -> Result<Thread> {
        let request_id = self.next_request_id();
        let response: ThreadUnarchiveResponse = self
            .client
            .request_typed(ClientRequest::ThreadUnarchive {
                request_id,
                params: ThreadUnarchiveParams {
                    thread_id: thread_id.to_string(),
                },
            })
            .await
            .wrap_err("failed to unarchive session")?;
        Ok(response.thread)
    }

    pub(crate) async fn thread_metadata_update_branch(
        &mut self,
        thread_id: ThreadId,
        branch: String,
    ) -> Result<ThreadMetadataUpdateResponse> {
        let request_id = self.next_request_id();
        self.client
            .request_typed(ClientRequest::ThreadMetadataUpdate {
                request_id,
                params: ThreadMetadataUpdateParams {
                    thread_id: thread_id.to_string(),
                    git_info: Some(ThreadMetadataGitInfoUpdateParams {
                        sha: None,
                        branch: Some(Some(branch)),
                        origin_url: None,
                    }),
                },
            })
            .await
            .wrap_err("thread/metadata/update failed while syncing git branch")
    }

    pub(crate) async fn thread_settings_update(
        &mut self,
        params: ThreadSettingsUpdateParams,
    ) -> Result<()> {
        if !self.thread_settings_update_supported {
            return Ok(());
        }
        let request_id = self.next_request_id();
        match self
            .client
            .request_typed::<ThreadSettingsUpdateResponse>(ClientRequest::ThreadSettingsUpdate {
                request_id,
                params,
            })
            .await
        {
            Ok(_) => Ok(()),
            Err(TypedRequestError::Server { source, .. })
                if is_thread_settings_update_unsupported(&source) =>
            {
                // Older remote app servers can reject this experimental method as
                // method-not-found, experimental-capability-gated, or an unknown
                // request variant. Treat those as a session-level capability
                // downgrade so local TUI setting changes stay best-effort instead
                // of showing an error every time the user changes model, effort,
                // personality, or mode.
                self.thread_settings_update_supported = false;
                Ok(())
            }
            Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"),
        }
    }

    pub(crate) async fn thread_inject_items(
        &mut self,
        thread_id: ThreadId,
        items: Vec<ResponseItem>,
    ) -> Result<ThreadInjectItemsResponse> {
        let items = items
            .into_iter()
            .map(serde_json::to_value)
            .collect::<std::result::Result<Vec<_>, _>>()
            .wrap_err("failed to encode thread/inject_items payload")?;
        let request_id = self.next_request_id();
        self.client
            .request_typed(ClientRequest::ThreadInjectItems {
                request_id,
                params: ThreadInjectItemsParams {
                    thread_id: thread_id.to_string(),
                    items,
                },
            })
            .await
            .wrap_err("thread/inject_items failed during TUI side conversation setup")
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) async fn turn_start(
        &mut self,
        thread_id: ThreadId,
        items: Vec<UserInput>,
        cwd: PathBuf,
        approval_policy: AskForApproval,
        approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
        permissions_override: TurnPermissionsOverride,
        workspace_roots: &[AbsolutePathBuf],
        model: String,
        effort: Option<codex_protocol::openai_models::ReasoningEffort>,
        summary: Option<codex_protocol::config_types::ReasoningSummary>,
        service_tier: Option<Option<String>>,
        collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
        personality: Option<codex_protocol::config_types::Personality>,
        output_schema: Option<serde_json::Value>,
    ) -> Result<TurnStartResponse> {
        let request_id = self.next_request_id();
        let (sandbox_policy, permissions) =
            turn_permissions_overrides(permissions_override, cwd.as_path());
        self.client
            .request_typed(ClientRequest::TurnStart {
                request_id,
                params: TurnStartParams {
                    thread_id: thread_id.to_string(),
                    client_user_message_id: None,
                    input: items,
                    responsesapi_client_metadata: None,
                    additional_context: None,
                    environments: None,
                    cwd: Some(cwd),
                    runtime_workspace_roots: Some(workspace_roots.to_vec()),
                    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,
    ) -> std::result::Result<(), TypedRequestError> {
        let request_id = self.next_request_id();
        let _: TurnInterruptResponse = self
            .client
            .request_typed(ClientRequest::TurnInterrupt {
                request_id,
                params: TurnInterruptParams {
                    thread_id: thread_id.to_string(),
                    turn_id,
                },
            })
            .await?;
        Ok(())
    }

    pub(crate) async fn startup_interrupt(
        &mut self,
        thread_id: ThreadId,
    ) -> std::result::Result<(), TypedRequestError> {
        self.turn_interrupt(thread_id, String::new()).await
    }

    pub(crate) async fn turn_steer(
        &mut self,
        thread_id: ThreadId,
        turn_id: String,
        items: Vec<UserInput>,
    ) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
        let request_id = self.next_request_id();
        self.client
            .request_typed(ClientRequest::TurnSteer {
                request_id,
                params: TurnSteerParams {
                    thread_id: thread_id.to_string(),
                    client_user_message_id: None,
                    input: items,
                    responsesapi_client_metadata: None,
                    additional_context: None,
                    expected_turn_id: turn_id,
                },
            })
            .await
    }

    pub(crate) async fn thread_set_name(
        &mut self,
        thread_id: ThreadId,
        name: String,
    ) -> Result<()> {
        let request_id = self.next_request_id();
        let _: ThreadSetNameResponse = self
            .client
            .request_typed(ClientRequest::ThreadSetName {
                request_id,
                params: ThreadSetNameParams {
                    thread_id: thread_id.to_string(),
                    name,
                },
            })
            .await
            .wrap_err("thread/name/set failed in TUI")?;
        Ok(())
    }

    pub(crate) async fn thread_memory_mode_set(
        &mut self,
        thread_id: ThreadId,
        mode: ThreadMemoryMode,
    ) -> Result<()> {
        let request_id = self.next_request_id();
        let _: ThreadMemoryModeSetResponse = self
            .client
            .request_typed(ClientRequest::ThreadMemoryModeSet {
                request_id,
                params: ThreadMemoryModeSetParams {
                    thread_id: thread_id.to_string(),
                    mode,
                },
            })
            .await
            .wrap_err("thread/memoryMode/set failed in TUI")?;
        Ok(())
    }

    pub(crate) async fn memory_reset(&mut self) -> Result<()> {
        let request_id = self.next_request_id();
        let _: MemoryResetResponse = self
            .client
            .request_typed(ClientRequest::MemoryReset {
                request_id,
                params: None,
            })
            .await
            .wrap_err("memory/reset failed in TUI")?;
        Ok(())
    }

    pub(crate) async fn thread_goal_get(
        &mut self,
        thread_id: ThreadId,
    ) -> Result<ThreadGoalGetResponse> {
        let request_id = self.next_request_id();
        self.client
            .request_typed(ClientRequest::ThreadGoalGet {
                request_id,
                params: ThreadGoalGetParams {
                    thread_id: thread_id.to_string(),
                },
            })
            .await
            .wrap_err("thread/goal/get failed in TUI")
    }

    pub(crate) async fn thread_goal_set(
        &mut self,
        thread_id: ThreadId,
        objective: Option<String>,
        status: Option<ThreadGoalStatus>,
        token_budget: Option<Option<i64>>,
    ) -> Result<ThreadGoalSetResponse> {
        let request_id = self.next_request_id();
        self.client
            .request_typed(ClientRequest::ThreadGoalSet {
                request_id,
                params: ThreadGoalSetParams {
                    thread_id: thread_id.to_string(),
                    objective,
                    status,
                    token_budget,
                },
            })
            .await
            .wrap_err("thread/goal/set failed in TUI")
    }

    pub(crate) async fn thread_goal_clear(
        &mut self,
        thread_id: ThreadId,
    ) -> Result<ThreadGoalClearResponse> {
        let request_id = self.next_request_id();
        self.client
            .request_typed(ClientRequest::ThreadGoalClear {
                request_id,
                params: ThreadGoalClearParams {
                    thread_id: thread_id.to_string(),
                },
            })
            .await
            .wrap_err("thread/goal/clear failed in TUI")
    }

    pub(crate) async fn logout_account(&mut self) -> Result<()> {
        let request_id = self.next_request_id();
        let _: LogoutAccountResponse = self
            .client
            .request_typed(ClientRequest::LogoutAccount {
                request_id,
                params: None,
            })
            .await
            .wrap_err("account/logout failed in TUI")?;
        Ok(())
    }

    pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> {
        let request_id = self.next_request_id();
        let _: ThreadUnsubscribeResponse = self
            .client
            .request_typed(ClientRequest::ThreadUnsubscribe {
                request_id,
                params: ThreadUnsubscribeParams {
                    thread_id: thread_id.to_string(),
                },
            })
            .await
            .wrap_err("thread/unsubscribe failed in TUI")?;
        Ok(())
    }

    pub(crate) async fn thread_compact_start(&mut self, thread_id: ThreadId) -> Result<()> {
        let request_id = self.next_request_id();
        let _: ThreadCompactStartResponse = self
            .client
            .request_typed(ClientRequest::ThreadCompactStart {
                request_id,
                params: ThreadCompactStartParams {
                    thread_id: thread_id.to_string(),
                },
            })
            .await
            .wrap_err("thread/compact/start failed in TUI")?;
        Ok(())
    }

    pub(crate) async fn thread_shell_command(
        &mut self,
        thread_id: ThreadId,
        command: String,
    ) -> Result<()> {
        let request_id = self.next_request_id();
        let _: ThreadShellCommandResponse = self
            .client
            .request_typed(ClientRequest::ThreadShellCommand {
                request_id,
                params: ThreadShellCommandParams {
                    thread_id: thread_id.to_string(),
                    command,
                },
            })
            .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 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)
    }
}

pub(crate) async fn start_thread_with_request_handle(
    request_handle: AppServerRequestHandle,
    config: Config,
    thread_params_mode: ThreadParamsMode,
    remote_cwd_override: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
    let response: ThreadStartResponse = request_handle
        .request_typed(ClientRequest::ThreadStart {
            request_id: RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())),
            params: thread_start_params_from_config(
                &config,
                thread_params_mode,
                remote_cwd_override.as_deref(),
                /*session_start_source*/ None,
            ),
        })
        .await
        .map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?;
    started_thread_from_start_response(response, &config, thread_params_mode).await
}

pub(crate) fn status_account_display_from_auth_mode(
    auth_mode: Option<AuthMode>,
    plan_type: Option<codex_protocol::account::PlanType>,
) -> Option<StatusAccountDisplay> {
    match auth_mode {
        Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey),
        Some(AuthMode::Chatgpt)
        | Some(AuthMode::ChatgptAuthTokens)
        | Some(AuthMode::AgentIdentity)
        | Some(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt {
            email: None,
            plan: plan_type.map(plan_type_display_name),
        }),
        Some(AuthMode::BedrockApiKey) => None,
        None => None,
    }
}

fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
    let upgrade = model.upgrade.map(|upgrade_id| {
        let upgrade_info = model.upgrade_info.clone();
        ModelUpgrade {
            id: upgrade_id,
            migration_config_key: model.model.clone(),
            model_link: upgrade_info
                .as_ref()
                .and_then(|info| info.model_link.clone()),
            upgrade_copy: upgrade_info
                .as_ref()
                .and_then(|info| info.upgrade_copy.clone()),
            migration_markdown: upgrade_info.and_then(|info| info.migration_markdown),
        }
    });

    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(),
        default_service_tier: model.default_service_tier,
        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(
        "model_reasoning_effort",
        config
            .model_reasoning_effort
            .as_ref()
            .map(std::string::ToString::to_string),
    );
    insert(
        "model_reasoning_summary",
        config
            .model_reasoning_summary
            .map(|summary| summary.to_string()),
    );
    insert(
        "model_verbosity",
        config
            .model_verbosity
            .map(|verbosity| verbosity.to_string()),
    );
    insert(
        "personality",
        config
            .personality
            .map(|personality| personality.to_string()),
    );
    insert(
        "web_search",
        Some(config.web_search_mode.value().to_string()),
    );
    if config.bypass_hook_trust {
        overrides.insert("bypass_hook_trust".to_string(), true.into());
    }
    Some(overrides)
}

fn 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(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()))
    })
}

fn sandbox_mode_from_permission_profile(
    permission_profile: &PermissionProfile,
    cwd: &std::path::Path,
) -> Option<codex_app_server_protocol::SandboxMode> {
    match permission_profile {
        PermissionProfile::Disabled => {
            Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
        }
        PermissionProfile::External { .. } => None,
        PermissionProfile::Managed { .. } => {
            let file_system_policy = permission_profile.file_system_sandbox_policy();
            if file_system_policy.has_full_disk_write_access() {
                permission_profile
                    .network_sandbox_policy()
                    .is_enabled()
                    .then_some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
            } else if file_system_policy.can_write_path_with_cwd(cwd, cwd) {
                Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
            } else {
                Some(codex_app_server_protocol::SandboxMode::ReadOnly)
            }
        }
    }
}

fn permission_profile_id_from_active_profile(active: ActivePermissionProfile) -> String {
    active.id
}

fn turn_permissions_overrides(
    permissions_override: TurnPermissionsOverride,
    cwd: &std::path::Path,
) -> (
    Option<codex_app_server_protocol::SandboxPolicy>,
    Option<String>,
) {
    match permissions_override {
        TurnPermissionsOverride::Preserve => (None, None),
        TurnPermissionsOverride::ActiveProfile(active_permission_profile) => (
            None,
            Some(permission_profile_id_from_active_profile(
                active_permission_profile,
            )),
        ),
        TurnPermissionsOverride::LegacySandbox(permission_profile) => {
            let 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}"
                    )
                });
            (Some(policy.into()), None)
        }
    }
}

fn permissions_selection_from_config(
    config: &Config,
    thread_params_mode: ThreadParamsMode,
) -> Option<String> {
    if matches!(thread_params_mode, ThreadParamsMode::Remote) {
        return None;
    }

    config
        .permissions
        .active_permission_profile()
        .map(permission_profile_id_from_active_profile)
}

fn thread_start_params_from_config(
    config: &Config,
    thread_params_mode: ThreadParamsMode,
    remote_cwd_override: Option<&std::path::Path>,
    session_start_source: Option<ThreadStartSource>,
) -> ThreadStartParams {
    let permissions = permissions_selection_from_config(config, thread_params_mode);
    let sandbox = permissions
        .is_none()
        .then(|| {
            sandbox_mode_from_permission_profile(
                &config.permissions.effective_permission_profile(),
                config.cwd.as_path(),
            )
        })
        .flatten();
    ThreadStartParams {
        model: config.model.clone(),
        model_provider: thread_params_mode.model_provider_from_config(config),
        service_tier: service_tier_override_from_config(config),
        cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override),
        runtime_workspace_roots: Some(config.workspace_roots.clone()),
        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),
        developer_instructions: with_terminal_visualization_instructions(
            config, /*control_instructions*/ None,
        ),
        ..ThreadStartParams::default()
    }
}

fn thread_resume_params_from_config(
    config: Config,
    thread_id: ThreadId,
    thread_params_mode: ThreadParamsMode,
    remote_cwd_override: Option<&std::path::Path>,
) -> 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),
        runtime_workspace_roots: Some(config.workspace_roots.clone()),
        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),
        developer_instructions: with_terminal_visualization_instructions(
            &config, /*control_instructions*/ None,
        ),
        ..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),
        runtime_workspace_roots: Some(config.workspace_roots.clone()),
        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: with_terminal_visualization_instructions(
            &config,
            config.developer_instructions.clone(),
        ),
        ephemeral: config.ephemeral,
        thread_source: Some(ThreadSource::User),
        ..ThreadForkParams::default()
    }
}

fn thread_cwd_from_config(
    config: &Config,
    thread_params_mode: ThreadParamsMode,
    remote_cwd_override: Option<&std::path::Path>,
) -> Option<String> {
    match thread_params_mode {
        ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()),
        ThreadParamsMode::Remote => {
            remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string())
        }
    }
}

async fn started_thread_from_start_response(
    response: ThreadStartResponse,
    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 = display_permission_profile_from_thread_response(
        &response.sandbox,
        response.cwd.as_path(),
        config,
        thread_params_mode,
    );
    thread_session_state_from_thread_response(
        &response.thread.id,
        response.thread.forked_from_id.clone(),
        response.thread.name.clone(),
        response.thread.path.clone(),
        response.model.clone(),
        response.model_provider.clone(),
        response.service_tier.clone(),
        response.approval_policy,
        response.approvals_reviewer.to_core(),
        permission_profile,
        response.active_permission_profile.clone().map(Into::into),
        response.cwd.clone(),
        response.runtime_workspace_roots.clone(),
        response.instruction_sources.clone(),
        response.reasoning_effort.clone(),
        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 = if matches!(thread_params_mode, ThreadParamsMode::Embedded)
        && response.active_permission_profile.is_none()
    {
        PermissionProfile::from_legacy_sandbox_policy_for_cwd(
            &response.sandbox.to_core(),
            response.cwd.as_path(),
        )
    } else {
        display_permission_profile_from_thread_response(
            &response.sandbox,
            response.cwd.as_path(),
            config,
            thread_params_mode,
        )
    };
    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.runtime_workspace_roots.clone(),
        response.instruction_sources.clone(),
        response.reasoning_effort.clone(),
        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 = display_permission_profile_from_thread_response(
        &response.sandbox,
        response.cwd.as_path(),
        config,
        thread_params_mode,
    );
    thread_session_state_from_thread_response(
        &response.thread.id,
        response.thread.forked_from_id.clone(),
        response.thread.name.clone(),
        response.thread.path.clone(),
        response.model.clone(),
        response.model_provider.clone(),
        response.service_tier.clone(),
        response.approval_policy,
        response.approvals_reviewer.to_core(),
        permission_profile,
        response.active_permission_profile.clone().map(Into::into),
        response.cwd.clone(),
        response.runtime_workspace_roots.clone(),
        response.instruction_sources.clone(),
        response.reasoning_effort.clone(),
        config,
    )
    .await
}

fn display_permission_profile_from_thread_response(
    sandbox: &codex_app_server_protocol::SandboxPolicy,
    cwd: &std::path::Path,
    config: &Config,
    thread_params_mode: ThreadParamsMode,
) -> PermissionProfile {
    match thread_params_mode {
        ThreadParamsMode::Embedded => 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,
    runtime_workspace_roots: Vec<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,
        runtime_workspace_roots,
        instruction_source_paths,
        reasoning_effort,
        collaboration_mode: None,
        personality: config.personality,
        message_history: Some(MessageHistoryMetadata {
            log_id,
            entry_count,
        }),
        network_proxy: None,
        rollout_path,
    })
}

pub(crate) fn app_server_rate_limit_snapshots(
    response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
    let primary_limit_id = response.rate_limits.limit_id.clone();
    let mut snapshots = vec![response.rate_limits];
    if let Some(by_limit_id) = response.rate_limits_by_limit_id {
        snapshots.extend(by_limit_id.into_iter().filter_map(|(limit_id, snapshot)| {
            if primary_limit_id.as_deref().is_some_and(|primary_limit_id| {
                primary_limit_id == limit_id
                    || Some(primary_limit_id) == snapshot.limit_id.as_deref()
            }) {
                None
            } else {
                Some(snapshot)
            }
        }));
    }
    snapshots
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::legacy_core::config::ConfigBuilder;
    use crate::legacy_core::config::ConfigOverrides;
    use codex_app_server_protocol::ThreadStatus;
    use codex_app_server_protocol::Turn;
    use codex_app_server_protocol::TurnStatus;
    use codex_features::Feature;
    use codex_protocol::config_types::Personality;
    use codex_protocol::config_types::ReasoningSummary;
    use codex_protocol::config_types::ServiceTier;
    use codex_protocol::config_types::Verbosity;
    use codex_protocol::config_types::WebSearchMode;
    use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
    use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
    use codex_protocol::models::ManagedFileSystemPermissions;
    use codex_protocol::openai_models::ReasoningEffort;
    use codex_protocol::permissions::FileSystemAccessMode;
    use codex_protocol::permissions::FileSystemPath;
    use codex_protocol::permissions::FileSystemSandboxEntry;
    use codex_protocol::permissions::FileSystemSpecialPath;
    use codex_protocol::permissions::NetworkSandboxPolicy;
    use codex_utils_absolute_path::test_support::PathBufExt;
    use codex_utils_absolute_path::test_support::test_path_buf;
    use 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")
    }

    fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot {
        RateLimitSnapshot {
            limit_id: Some(limit_id.to_string()),
            limit_name: None,
            primary: Some(codex_app_server_protocol::RateLimitWindow {
                used_percent: 0,
                window_duration_mins: Some(10_080),
                resets_at: None,
            }),
            secondary: None,
            credits: None,
            individual_limit: None,
            plan_type: None,
            rate_limit_reached_type: None,
        }
    }

    #[test]
    fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() {
        let response = GetAccountRateLimitsResponse {
            rate_limits: rate_limit_snapshot("codex"),
            rate_limits_by_limit_id: Some(HashMap::from([
                ("codex".to_string(), rate_limit_snapshot("codex")),
                ("other".to_string(), rate_limit_snapshot("other")),
            ])),
        };

        let snapshots = app_server_rate_limit_snapshots(response);

        assert_eq!(
            snapshots
                .iter()
                .map(|snapshot| snapshot.limit_id.as_deref())
                .collect::<Vec<_>>(),
            vec![Some("codex"), Some("other")]
        );
    }

    #[test]
    fn thread_settings_update_compat_detects_unsupported_errors() {
        let cases = [
            (JSONRPC_METHOD_NOT_FOUND, "method not found", true),
            (
                JSONRPC_INVALID_REQUEST,
                "thread/settings/update requires experimentalApi capability",
                true,
            ),
            (
                JSONRPC_INVALID_REQUEST,
                "Invalid request: unknown variant `thread/settings/update`",
                true,
            ),
            (JSONRPC_INVALID_REQUEST, "invalid thread id", false),
        ];

        for (code, message, expected) in cases {
            let source = JSONRPCErrorError {
                code,
                data: None,
                message: message.to_string(),
            };
            assert_eq!(
                is_thread_settings_update_unsupported(&source),
                expected,
                "{message}"
            );
        }
    }

    #[tokio::test]
    async fn thread_start_params_include_cwd_for_embedded_sessions() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let config = ConfigBuilder::default()
            .codex_home(temp_dir.path().to_path_buf())
            .harness_overrides(ConfigOverrides {
                default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
                ..ConfigOverrides::default()
            })
            .build()
            .await
            .expect("config should build");

        let params = thread_start_params_from_config(
            &config,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
            /*session_start_source*/ None,
        );

        assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string()));
        assert_eq!(
            params.runtime_workspace_roots,
            Some(config.workspace_roots.clone())
        );
        assert_eq!(params.sandbox, None);
        assert_eq!(
            params.permissions,
            config
                .permissions
                .active_permission_profile()
                .map(permission_profile_id_from_active_profile)
        );
        assert_eq!(params.model_provider, Some(config.model_provider_id));
        assert_eq!(params.thread_source, Some(ThreadSource::User));
    }

    #[tokio::test]
    async fn thread_start_params_can_mark_clear_source() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let config = build_config(&temp_dir).await;

        let params = thread_start_params_from_config(
            &config,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
            Some(ThreadStartSource::Clear),
        );

        assert_eq!(params.session_start_source, Some(ThreadStartSource::Clear));
    }

    #[test]
    fn embedded_turn_permissions_use_active_profile_selection() {
        let cwd = test_path_buf("/workspace/project").abs();
        let active_permission_profile =
            ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);
        let expected_permissions =
            permission_profile_id_from_active_profile(active_permission_profile.clone());

        let (sandbox_policy, permissions) = turn_permissions_overrides(
            TurnPermissionsOverride::ActiveProfile(active_permission_profile),
            cwd.as_path(),
        );

        assert_eq!(sandbox_policy, None);
        assert_eq!(permissions, Some(expected_permissions));
    }

    #[test]
    fn embedded_turn_permissions_select_profile_id_only() {
        let cwd = test_path_buf("/workspace/project").abs();
        let active_permission_profile =
            ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE);

        let (sandbox_policy, permissions) = turn_permissions_overrides(
            TurnPermissionsOverride::ActiveProfile(active_permission_profile),
            cwd.as_path(),
        );

        assert_eq!(sandbox_policy, None);
        assert_eq!(
            permissions,
            Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
        );
    }

    #[test]
    fn turn_permissions_preserve_thread_permissions_without_override() {
        let cwd = test_path_buf("/workspace/project").abs();

        let (sandbox_policy, permissions) =
            turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path());

        assert_eq!(sandbox_policy, None);
        assert_eq!(permissions, None);
    }

    #[test]
    fn legacy_turn_permissions_project_to_sandbox_when_explicitly_overridden() {
        let cwd = test_path_buf("/workspace/project").abs();

        let (sandbox_policy, permissions) = turn_permissions_overrides(
            TurnPermissionsOverride::LegacySandbox(PermissionProfile::read_only()),
            cwd.as_path(),
        );

        assert_eq!(
            sandbox_policy,
            Some(codex_app_server_protocol::SandboxPolicy::ReadOnly {
                network_access: false
            })
        );
        assert_eq!(permissions, None);
    }

    #[test]
    fn remote_turn_permissions_preserve_active_profile_selection() {
        let cwd = test_path_buf("/workspace/project").abs();
        let active_permission_profile = ActivePermissionProfile::new("strict");
        let expected_permissions =
            permission_profile_id_from_active_profile(active_permission_profile.clone());

        let (sandbox_policy, permissions) = turn_permissions_overrides(
            TurnPermissionsOverride::ActiveProfile(active_permission_profile),
            cwd.as_path(),
        );

        assert_eq!(sandbox_policy, None);
        assert_eq!(permissions, Some(expected_permissions));
    }

    #[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 expected_runtime_workspace_roots = Some(config.workspace_roots.clone());

        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.runtime_workspace_roots,
            expected_runtime_workspace_roots
        );
        assert_eq!(
            resume.runtime_workspace_roots,
            expected_runtime_workspace_roots
        );
        assert_eq!(
            fork.runtime_workspace_roots,
            expected_runtime_workspace_roots
        );
        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 = PermissionProfile::Managed {
            network: NetworkSandboxPolicy::Restricted,
            file_system: ManagedFileSystemPermissions::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,
            },
        };

        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 = PermissionProfile::Managed {
            network: NetworkSandboxPolicy::Restricted,
            file_system: ManagedFileSystemPermissions::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,
            },
        };

        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_config_overrides_and_service_tier() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let mut config = build_config(&temp_dir).await;
        config.model_reasoning_effort = Some(ReasoningEffort::High);
        config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
        config.model_verbosity = Some(Verbosity::Low);
        config.personality = Some(Personality::Pragmatic);
        config
            .web_search_mode
            .set(WebSearchMode::Disabled)
            .expect("test web search mode should be allowed");
        config.bypass_hook_trust = true;
        config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
        let thread_id = ThreadId::new();

        let start = thread_start_params_from_config(
            &config,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
            /*session_start_source*/ None,
        );
        let resume = thread_resume_params_from_config(
            config.clone(),
            thread_id,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
        );
        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")),
            ("bypass_hook_trust".to_string(), true.into()),
        ]);
        assert_eq!(start.config, Some(expected_config.clone()));
        assert_eq!(resume.config, Some(expected_config.clone()));
        assert_eq!(fork.config, Some(expected_config));
    }

    #[tokio::test]
    async fn config_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 terminal_visualization_instructions_are_gated_for_all_tui_thread_flows() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let mut config = build_config(&temp_dir).await;
        config.developer_instructions = Some("Developer override.".to_string());
        let thread_id = ThreadId::new();

        let control_start = thread_start_params_from_config(
            &config,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
            /*session_start_source*/ None,
        );
        let control_resume = thread_resume_params_from_config(
            config.clone(),
            thread_id,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
        );
        let control_fork = thread_fork_params_from_config(
            config.clone(),
            thread_id,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
        );

        assert_eq!(control_start.developer_instructions, None);
        assert_eq!(control_resume.developer_instructions, None);
        assert_eq!(
            control_fork.developer_instructions.as_deref(),
            Some("Developer override.")
        );

        let _ = config
            .features
            .enable(Feature::TerminalVisualizationInstructions);
        let treatment_start = thread_start_params_from_config(
            &config,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
            /*session_start_source*/ None,
        );
        let treatment_resume = thread_resume_params_from_config(
            config.clone(),
            thread_id,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
        );
        let treatment_fork = thread_fork_params_from_config(
            config,
            thread_id,
            ThreadParamsMode::Embedded,
            /*remote_cwd_override*/ None,
        );
        let expected = format!(
            "Developer override.\n\n{}",
            crate::terminal_visualization_instructions::TERMINAL_VISUALIZATION_INSTRUCTIONS
        );

        assert_eq!(
            treatment_start.developer_instructions.as_deref(),
            Some(expected.as_str())
        );
        assert_eq!(
            treatment_resume.developer_instructions.as_deref(),
            Some(expected.as_str())
        );
        assert_eq!(
            treatment_fork.developer_instructions.as_deref(),
            Some(expected.as_str())
        );
    }

    #[tokio::test]
    async fn resume_response_restores_turns_from_thread_items() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let config = build_config(&temp_dir).await;
        let thread_id = ThreadId::new();
        let forked_from_id = ThreadId::new();
        let read_only_profile = PermissionProfile::read_only();
        let response = ThreadResumeResponse {
            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()),
                parent_thread_id: None,
                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(),
                            client_id: None,
                            content: vec![codex_app_server_protocol::UserInput::Text {
                                text: "hello from history".to_string(),
                                text_elements: Vec::new(),
                            }],
                        },
                        codex_app_server_protocol::ThreadItem::AgentMessage {
                            id: "assistant-1".to_string(),
                            text: "assistant reply".to_string(),
                            phase: None,
                            memory_citation: None,
                        },
                    ],
                    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(),
            runtime_workspace_roots: vec![
                test_path_buf("/tmp/project").abs(),
                test_path_buf("/tmp/project/extra").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(),
            active_permission_profile: None,
            reasoning_effort: None,
            initial_turns_page: 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.runtime_workspace_roots,
            response.runtime_workspace_roots
        );
        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]);

        let embedded_config = ConfigBuilder::default()
            .codex_home(temp_dir.path().join("embedded-codex-home"))
            .harness_overrides(ConfigOverrides {
                default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
                ..ConfigOverrides::default()
            })
            .build()
            .await
            .expect("config should build");
        let started = started_thread_from_resume_response(
            response.clone(),
            &embedded_config,
            ThreadParamsMode::Embedded,
        )
        .await
        .expect("embedded resume response should map");
        assert_eq!(started.session.permission_profile, read_only_profile);

        let mut empty_roots_response = response;
        empty_roots_response.runtime_workspace_roots = Vec::new();
        let started = started_thread_from_resume_response(
            empty_roots_response,
            &config,
            ThreadParamsMode::Remote,
        )
        .await
        .expect("resume response should map");
        assert_eq!(started.session.runtime_workspace_roots, Vec::new());
    }

    #[tokio::test]
    async fn remote_thread_response_uses_legacy_sandbox_fallback() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let config = build_config(&temp_dir).await;
        let cwd = test_path_buf("/tmp/project").abs();
        let sandbox = PermissionProfile::read_only()
            .to_legacy_sandbox_policy(cwd.as_path())
            .expect("read-only profile must be legacy-compatible")
            .into();

        assert_eq!(
            display_permission_profile_from_thread_response(
                &sandbox,
                cwd.as_path(),
                &config,
                ThreadParamsMode::Remote,
            ),
            PermissionProfile::read_only()
        );
    }

    #[tokio::test]
    async fn embedded_thread_response_uses_local_config_profile() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let config = ConfigBuilder::default()
            .codex_home(temp_dir.path().to_path_buf())
            .harness_overrides(ConfigOverrides {
                default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
                ..ConfigOverrides::default()
            })
            .build()
            .await
            .expect("config should build");
        let cwd = test_path_buf("/tmp/project").abs();

        assert_eq!(
            display_permission_profile_from_thread_response(
                &codex_app_server_protocol::SandboxPolicy::DangerFullAccess,
                cwd.as_path(),
                &config,
                ThreadParamsMode::Embedded,
            ),
            PermissionProfile::read_only()
        );
    }

    #[tokio::test]
    async fn session_configured_populates_history_metadata() {
        let temp_dir = tempfile::tempdir().expect("tempdir");
        let config = build_config(&temp_dir).await;
        let thread_id = ThreadId::new();

        let history_config =
            codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history);

        codex_message_history::append_entry("older", &thread_id, &history_config)
            .await
            .expect("history append should succeed");
        codex_message_history::append_entry("newer", &thread_id, &history_config)
            .await
            .expect("history append should succeed");

        let session = thread_session_state_from_thread_response(
            &thread_id.to_string(),
            /*forked_from_id*/ None,
            Some("restore".to_string()),
            /*rollout_path*/ None,
            "gpt-5.4".to_string(),
            "openai".to_string(),
            /*service_tier*/ None,
            AskForApproval::Never,
            codex_protocol::config_types::ApprovalsReviewer::User,
            PermissionProfile::read_only(),
            /*active_permission_profile*/ None,
            test_path_buf("/tmp/project").abs(),
            Vec::new(),
            Vec::new(),
            /*reasoning_effort*/ None,
            &config,
        )
        .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(),
            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"
        ));
    }
}
