mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
db89b73a9c
This PR replicates the `tui` code directory and creates a temporary parallel `tui_app_server` directory. It also implements a new feature flag `tui_app_server` to select between the two tui implementations. Once the new app-server-based TUI is stabilized, we'll delete the old `tui` directory and feature flag.
1283 lines
46 KiB
Rust
1283 lines
46 KiB
Rust
use codex_app_server_client::AppServerClient;
|
|
use codex_app_server_client::AppServerEvent;
|
|
use codex_app_server_client::AppServerRequestHandle;
|
|
use codex_app_server_protocol::Account;
|
|
use codex_app_server_protocol::AuthMode;
|
|
use codex_app_server_protocol::ClientRequest;
|
|
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::Model as ApiModel;
|
|
use codex_app_server_protocol::ModelListParams;
|
|
use codex_app_server_protocol::ModelListResponse;
|
|
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::SkillsListParams;
|
|
use codex_app_server_protocol::SkillsListResponse;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
|
|
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
|
|
use codex_app_server_protocol::ThreadCompactStartParams;
|
|
use codex_app_server_protocol::ThreadCompactStartResponse;
|
|
use codex_app_server_protocol::ThreadForkParams;
|
|
use codex_app_server_protocol::ThreadForkResponse;
|
|
use codex_app_server_protocol::ThreadListParams;
|
|
use codex_app_server_protocol::ThreadListResponse;
|
|
use codex_app_server_protocol::ThreadReadParams;
|
|
use codex_app_server_protocol::ThreadReadResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
|
|
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeStartParams;
|
|
use codex_app_server_protocol::ThreadRealtimeStartResponse;
|
|
use codex_app_server_protocol::ThreadRealtimeStopParams;
|
|
use codex_app_server_protocol::ThreadRealtimeStopResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadRollbackParams;
|
|
use codex_app_server_protocol::ThreadRollbackResponse;
|
|
use codex_app_server_protocol::ThreadSetNameParams;
|
|
use codex_app_server_protocol::ThreadSetNameResponse;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
|
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
|
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_core::config::Config;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::items::AgentMessageContent;
|
|
use codex_protocol::items::AgentMessageItem;
|
|
use codex_protocol::items::ContextCompactionItem;
|
|
use codex_protocol::items::ImageGenerationItem;
|
|
use codex_protocol::items::PlanItem;
|
|
use codex_protocol::items::ReasoningItem;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_protocol::items::WebSearchItem;
|
|
use codex_protocol::openai_models::ModelAvailabilityNux;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffortPreset;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::ConversationAudioParams;
|
|
use codex_protocol::protocol::ConversationStartParams;
|
|
use codex_protocol::protocol::ConversationTextParams;
|
|
use codex_protocol::protocol::CreditsSnapshot;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ItemCompletedEvent;
|
|
use codex_protocol::protocol::RateLimitSnapshot;
|
|
use codex_protocol::protocol::RateLimitWindow;
|
|
use codex_protocol::protocol::ReviewRequest;
|
|
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionConfiguredEvent;
|
|
use color_eyre::eyre::ContextCompat;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::bottom_pane::FeedbackAudience;
|
|
use crate::status::StatusAccountDisplay;
|
|
|
|
pub(crate) struct AppServerBootstrap {
|
|
pub(crate) account_auth_mode: Option<AuthMode>,
|
|
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>,
|
|
pub(crate) default_model: String,
|
|
pub(crate) feedback_audience: FeedbackAudience,
|
|
pub(crate) has_chatgpt_account: bool,
|
|
pub(crate) available_models: Vec<ModelPreset>,
|
|
pub(crate) rate_limit_snapshots: Vec<RateLimitSnapshot>,
|
|
}
|
|
|
|
pub(crate) struct AppServerSession {
|
|
client: AppServerClient,
|
|
next_request_id: i64,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ThreadParamsMode {
|
|
Embedded,
|
|
Remote,
|
|
}
|
|
|
|
impl ThreadParamsMode {
|
|
fn model_provider_from_config(self, config: &Config) -> Option<String> {
|
|
match self {
|
|
Self::Embedded => Some(config.model_provider_id.clone()),
|
|
Self::Remote => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) struct AppServerStartedThread {
|
|
pub(crate) session_configured: SessionConfiguredEvent,
|
|
}
|
|
|
|
impl AppServerSession {
|
|
pub(crate) fn new(client: AppServerClient) -> Self {
|
|
Self {
|
|
client,
|
|
next_request_id: 1,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn is_remote(&self) -> bool {
|
|
matches!(self.client, AppServerClient::Remote(_))
|
|
}
|
|
|
|
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
|
|
let account_request_id = self.next_request_id();
|
|
let account: GetAccountResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::GetAccount {
|
|
request_id: account_request_id,
|
|
params: GetAccountParams {
|
|
refresh_token: false,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("account/read failed during TUI bootstrap")?;
|
|
let model_request_id = self.next_request_id();
|
|
let models: ModelListResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ModelList {
|
|
request_id: model_request_id,
|
|
params: ModelListParams {
|
|
cursor: None,
|
|
limit: None,
|
|
include_hidden: Some(true),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("model/list failed during TUI bootstrap")?;
|
|
let rate_limit_request_id = self.next_request_id();
|
|
let rate_limits: GetAccountRateLimitsResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::GetAccountRateLimits {
|
|
request_id: rate_limit_request_id,
|
|
params: None,
|
|
})
|
|
.await
|
|
.wrap_err("account/rateLimits/read failed during TUI bootstrap")?;
|
|
|
|
let available_models = models
|
|
.data
|
|
.into_iter()
|
|
.map(model_preset_from_api_model)
|
|
.collect::<Vec<_>>();
|
|
let default_model = config
|
|
.model
|
|
.clone()
|
|
.or_else(|| {
|
|
available_models
|
|
.iter()
|
|
.find(|model| model.is_default)
|
|
.map(|model| model.model.clone())
|
|
})
|
|
.or_else(|| available_models.first().map(|model| model.model.clone()))
|
|
.wrap_err("model/list returned no models for TUI bootstrap")?;
|
|
|
|
let (
|
|
account_auth_mode,
|
|
account_email,
|
|
auth_mode,
|
|
status_account_display,
|
|
plan_type,
|
|
feedback_audience,
|
|
has_chatgpt_account,
|
|
) = match account.account {
|
|
Some(Account::ApiKey {}) => (
|
|
Some(AuthMode::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(AuthMode::Chatgpt),
|
|
Some(email.clone()),
|
|
Some(TelemetryAuthMode::Chatgpt),
|
|
Some(StatusAccountDisplay::ChatGpt {
|
|
email: Some(email),
|
|
plan: Some(title_case(format!("{plan_type:?}").as_str())),
|
|
}),
|
|
Some(plan_type),
|
|
feedback_audience,
|
|
true,
|
|
)
|
|
}
|
|
None => (
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
FeedbackAudience::External,
|
|
false,
|
|
),
|
|
};
|
|
|
|
Ok(AppServerBootstrap {
|
|
account_auth_mode,
|
|
account_email,
|
|
auth_mode,
|
|
status_account_display,
|
|
plan_type,
|
|
default_model,
|
|
feedback_audience,
|
|
has_chatgpt_account,
|
|
available_models,
|
|
rate_limit_snapshots: app_server_rate_limit_snapshots_to_core(rate_limits),
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
|
|
self.client.next_event().await
|
|
}
|
|
|
|
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadStart {
|
|
request_id,
|
|
params: thread_start_params_from_config(config, self.thread_params_mode()),
|
|
})
|
|
.await
|
|
.wrap_err("thread/start failed during TUI bootstrap")?;
|
|
started_thread_from_start_response(&response)
|
|
}
|
|
|
|
pub(crate) async fn resume_thread(
|
|
&mut self,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<AppServerStartedThread> {
|
|
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadResumeResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadResume {
|
|
request_id,
|
|
params: thread_resume_params_from_config(
|
|
config,
|
|
thread_id,
|
|
self.thread_params_mode(),
|
|
),
|
|
})
|
|
.await
|
|
.wrap_err("thread/resume failed during TUI bootstrap")?;
|
|
started_thread_from_resume_response(&response, show_raw_agent_reasoning)
|
|
}
|
|
|
|
pub(crate) async fn fork_thread(
|
|
&mut self,
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
) -> Result<AppServerStartedThread> {
|
|
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
|
|
let request_id = self.next_request_id();
|
|
let response: ThreadForkResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadFork {
|
|
request_id,
|
|
params: thread_fork_params_from_config(
|
|
config,
|
|
thread_id,
|
|
self.thread_params_mode(),
|
|
),
|
|
})
|
|
.await
|
|
.wrap_err("thread/fork failed during TUI bootstrap")?;
|
|
started_thread_from_fork_response(&response, show_raw_agent_reasoning)
|
|
}
|
|
|
|
fn thread_params_mode(&self) -> ThreadParamsMode {
|
|
match &self.client {
|
|
AppServerClient::InProcess(_) => ThreadParamsMode::Embedded,
|
|
AppServerClient::Remote(_) => ThreadParamsMode::Remote,
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn turn_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
items: Vec<codex_protocol::user_input::UserInput>,
|
|
cwd: PathBuf,
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
|
|
sandbox_policy: SandboxPolicy,
|
|
model: String,
|
|
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
|
|
summary: Option<codex_protocol::config_types::ReasoningSummary>,
|
|
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
|
|
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
|
|
personality: Option<codex_protocol::config_types::Personality>,
|
|
output_schema: Option<serde_json::Value>,
|
|
) -> Result<TurnStartResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::TurnStart {
|
|
request_id,
|
|
params: TurnStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: items.into_iter().map(Into::into).collect(),
|
|
cwd: Some(cwd),
|
|
approval_policy: Some(approval_policy.into()),
|
|
approvals_reviewer: Some(approvals_reviewer.into()),
|
|
sandbox_policy: Some(sandbox_policy.into()),
|
|
model: Some(model),
|
|
service_tier,
|
|
effort,
|
|
summary,
|
|
personality,
|
|
output_schema,
|
|
collaboration_mode,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/start failed in app-server TUI")
|
|
}
|
|
|
|
pub(crate) async fn turn_interrupt(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: TurnInterruptResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::TurnInterrupt {
|
|
request_id,
|
|
params: TurnInterruptParams {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/interrupt failed in app-server TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn turn_steer(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
turn_id: String,
|
|
items: Vec<codex_protocol::user_input::UserInput>,
|
|
) -> Result<TurnSteerResponse> {
|
|
let request_id = self.next_request_id();
|
|
self.client
|
|
.request_typed(ClientRequest::TurnSteer {
|
|
request_id,
|
|
params: TurnSteerParams {
|
|
thread_id: thread_id.to_string(),
|
|
input: items.into_iter().map(Into::into).collect(),
|
|
expected_turn_id: turn_id,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("turn/steer failed in app-server TUI")
|
|
}
|
|
|
|
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 app-server 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 app-server 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 app-server 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 app-server 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 app-server TUI")
|
|
}
|
|
|
|
pub(crate) async fn review_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
review_request: ReviewRequest,
|
|
) -> 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: review_target_to_app_server(review_request.target),
|
|
delivery: Some(ReviewDelivery::Inline),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("review/start failed in app-server 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 app-server TUI")
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_start(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
params: ConversationStartParams,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeStartResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStart {
|
|
request_id,
|
|
params: ThreadRealtimeStartParams {
|
|
thread_id: thread_id.to_string(),
|
|
prompt: params.prompt,
|
|
session_id: params.session_id,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/start failed in app-server TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_audio(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
params: ConversationAudioParams,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeAppendAudioResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeAppendAudio {
|
|
request_id,
|
|
params: ThreadRealtimeAppendAudioParams {
|
|
thread_id: thread_id.to_string(),
|
|
audio: params.frame.into(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/appendAudio failed in app-server TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_text(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
params: ConversationTextParams,
|
|
) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeAppendTextResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeAppendText {
|
|
request_id,
|
|
params: ThreadRealtimeAppendTextParams {
|
|
thread_id: thread_id.to_string(),
|
|
text: params.text,
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/appendText failed in app-server TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
|
|
let request_id = self.next_request_id();
|
|
let _: ThreadRealtimeStopResponse = self
|
|
.client
|
|
.request_typed(ClientRequest::ThreadRealtimeStop {
|
|
request_id,
|
|
params: ThreadRealtimeStopParams {
|
|
thread_id: thread_id.to_string(),
|
|
},
|
|
})
|
|
.await
|
|
.wrap_err("thread/realtime/stop failed in app-server TUI")?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn reject_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
error: JSONRPCErrorError,
|
|
) -> std::io::Result<()> {
|
|
self.client.reject_server_request(request_id, error).await
|
|
}
|
|
|
|
pub(crate) async fn resolve_server_request(
|
|
&self,
|
|
request_id: RequestId,
|
|
result: serde_json::Value,
|
|
) -> std::io::Result<()> {
|
|
self.client.resolve_server_request(request_id, result).await
|
|
}
|
|
|
|
pub(crate) async fn shutdown(self) -> std::io::Result<()> {
|
|
self.client.shutdown().await
|
|
}
|
|
|
|
pub(crate) fn request_handle(&self) -> AppServerRequestHandle {
|
|
self.client.request_handle()
|
|
}
|
|
|
|
fn next_request_id(&mut self) -> RequestId {
|
|
let request_id = self.next_request_id;
|
|
self.next_request_id += 1;
|
|
RequestId::Integer(request_id)
|
|
}
|
|
}
|
|
|
|
fn title_case(s: &str) -> String {
|
|
if s.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
let mut chars = s.chars();
|
|
let Some(first) = chars.next() else {
|
|
return String::new();
|
|
};
|
|
let rest = chars.as_str().to_ascii_lowercase();
|
|
first.to_uppercase().collect::<String>() + &rest
|
|
}
|
|
|
|
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(StatusAccountDisplay::ChatGpt {
|
|
email: None,
|
|
plan: plan_type.map(|plan_type| title_case(format!("{plan_type:?}").as_str())),
|
|
})
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn feedback_audience_from_account_email(
|
|
account_email: Option<&str>,
|
|
) -> FeedbackAudience {
|
|
match account_email {
|
|
Some(email) if email.ends_with("@openai.com") => FeedbackAudience::OpenAiEmployee,
|
|
Some(_) | None => FeedbackAudience::External,
|
|
}
|
|
}
|
|
|
|
fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
|
|
let upgrade = model.upgrade.map(|upgrade_id| {
|
|
let upgrade_info = model.upgrade_info.clone();
|
|
ModelUpgrade {
|
|
id: upgrade_id,
|
|
reasoning_effort_mapping: None,
|
|
migration_config_key: model.model.clone(),
|
|
model_link: upgrade_info
|
|
.as_ref()
|
|
.and_then(|info| info.model_link.clone()),
|
|
upgrade_copy: upgrade_info
|
|
.as_ref()
|
|
.and_then(|info| info.upgrade_copy.clone()),
|
|
migration_markdown: upgrade_info.and_then(|info| info.migration_markdown),
|
|
}
|
|
});
|
|
|
|
ModelPreset {
|
|
id: model.id,
|
|
model: model.model,
|
|
display_name: model.display_name,
|
|
description: model.description,
|
|
default_reasoning_effort: model.default_reasoning_effort,
|
|
supported_reasoning_efforts: model
|
|
.supported_reasoning_efforts
|
|
.into_iter()
|
|
.map(|effort| ReasoningEffortPreset {
|
|
effort: effort.reasoning_effort,
|
|
description: effort.description,
|
|
})
|
|
.collect(),
|
|
supports_personality: model.supports_personality,
|
|
is_default: model.is_default,
|
|
upgrade,
|
|
show_in_picker: !model.hidden,
|
|
availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux {
|
|
message: nux.message,
|
|
}),
|
|
// `model/list` already returns models filtered for the active client/auth context.
|
|
supported_in_api: true,
|
|
input_modalities: model.input_modalities,
|
|
}
|
|
}
|
|
|
|
fn approvals_reviewer_override_from_config(
|
|
config: &Config,
|
|
) -> Option<codex_app_server_protocol::ApprovalsReviewer> {
|
|
Some(config.approvals_reviewer.into())
|
|
}
|
|
|
|
fn config_request_overrides_from_config(
|
|
config: &Config,
|
|
) -> Option<HashMap<String, serde_json::Value>> {
|
|
config.active_profile.as_ref().map(|profile| {
|
|
HashMap::from([(
|
|
"profile".to_string(),
|
|
serde_json::Value::String(profile.clone()),
|
|
)])
|
|
})
|
|
}
|
|
|
|
fn sandbox_mode_from_policy(
|
|
policy: SandboxPolicy,
|
|
) -> Option<codex_app_server_protocol::SandboxMode> {
|
|
match policy {
|
|
SandboxPolicy::DangerFullAccess => {
|
|
Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
|
|
}
|
|
SandboxPolicy::ReadOnly { .. } => Some(codex_app_server_protocol::SandboxMode::ReadOnly),
|
|
SandboxPolicy::WorkspaceWrite { .. } => {
|
|
Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
|
|
}
|
|
SandboxPolicy::ExternalSandbox { .. } => None,
|
|
}
|
|
}
|
|
|
|
fn thread_start_params_from_config(
|
|
config: &Config,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> ThreadStartParams {
|
|
ThreadStartParams {
|
|
model: config.model.clone(),
|
|
model_provider: thread_params_mode.model_provider_from_config(config),
|
|
cwd: thread_cwd_from_config(config, thread_params_mode),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(config),
|
|
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()),
|
|
config: config_request_overrides_from_config(config),
|
|
ephemeral: Some(config.ephemeral),
|
|
persist_extended_history: true,
|
|
..ThreadStartParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_resume_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> ThreadResumeParams {
|
|
ThreadResumeParams {
|
|
thread_id: thread_id.to_string(),
|
|
model: config.model.clone(),
|
|
model_provider: thread_params_mode.model_provider_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(&config),
|
|
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()),
|
|
config: config_request_overrides_from_config(&config),
|
|
persist_extended_history: true,
|
|
..ThreadResumeParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_fork_params_from_config(
|
|
config: Config,
|
|
thread_id: ThreadId,
|
|
thread_params_mode: ThreadParamsMode,
|
|
) -> ThreadForkParams {
|
|
ThreadForkParams {
|
|
thread_id: thread_id.to_string(),
|
|
model: config.model.clone(),
|
|
model_provider: thread_params_mode.model_provider_from_config(&config),
|
|
cwd: thread_cwd_from_config(&config, thread_params_mode),
|
|
approval_policy: Some(config.permissions.approval_policy.value().into()),
|
|
approvals_reviewer: approvals_reviewer_override_from_config(&config),
|
|
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()),
|
|
config: config_request_overrides_from_config(&config),
|
|
ephemeral: config.ephemeral,
|
|
persist_extended_history: true,
|
|
..ThreadForkParams::default()
|
|
}
|
|
}
|
|
|
|
fn thread_cwd_from_config(config: &Config, thread_params_mode: ThreadParamsMode) -> Option<String> {
|
|
match thread_params_mode {
|
|
ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()),
|
|
ThreadParamsMode::Remote => None,
|
|
}
|
|
}
|
|
|
|
fn started_thread_from_start_response(
|
|
response: &ThreadStartResponse,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session_configured = session_configured_from_thread_start_response(response)
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread { session_configured })
|
|
}
|
|
|
|
fn started_thread_from_resume_response(
|
|
response: &ThreadResumeResponse,
|
|
show_raw_agent_reasoning: bool,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session_configured = session_configured_from_thread_resume_response(response)
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session_configured: SessionConfiguredEvent {
|
|
initial_messages: thread_initial_messages(
|
|
&session_configured.session_id,
|
|
&response.thread.turns,
|
|
show_raw_agent_reasoning,
|
|
),
|
|
..session_configured
|
|
},
|
|
})
|
|
}
|
|
|
|
fn started_thread_from_fork_response(
|
|
response: &ThreadForkResponse,
|
|
show_raw_agent_reasoning: bool,
|
|
) -> Result<AppServerStartedThread> {
|
|
let session_configured = session_configured_from_thread_fork_response(response)
|
|
.map_err(color_eyre::eyre::Report::msg)?;
|
|
Ok(AppServerStartedThread {
|
|
session_configured: SessionConfiguredEvent {
|
|
initial_messages: thread_initial_messages(
|
|
&session_configured.session_id,
|
|
&response.thread.turns,
|
|
show_raw_agent_reasoning,
|
|
),
|
|
..session_configured
|
|
},
|
|
})
|
|
}
|
|
|
|
fn session_configured_from_thread_start_response(
|
|
response: &ThreadStartResponse,
|
|
) -> Result<SessionConfiguredEvent, String> {
|
|
session_configured_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier,
|
|
response.approval_policy.to_core(),
|
|
response.approvals_reviewer.to_core(),
|
|
response.sandbox.to_core(),
|
|
response.cwd.clone(),
|
|
response.reasoning_effort,
|
|
)
|
|
}
|
|
|
|
fn session_configured_from_thread_resume_response(
|
|
response: &ThreadResumeResponse,
|
|
) -> Result<SessionConfiguredEvent, String> {
|
|
session_configured_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier,
|
|
response.approval_policy.to_core(),
|
|
response.approvals_reviewer.to_core(),
|
|
response.sandbox.to_core(),
|
|
response.cwd.clone(),
|
|
response.reasoning_effort,
|
|
)
|
|
}
|
|
|
|
fn session_configured_from_thread_fork_response(
|
|
response: &ThreadForkResponse,
|
|
) -> Result<SessionConfiguredEvent, String> {
|
|
session_configured_from_thread_response(
|
|
&response.thread.id,
|
|
response.thread.name.clone(),
|
|
response.thread.path.clone(),
|
|
response.model.clone(),
|
|
response.model_provider.clone(),
|
|
response.service_tier,
|
|
response.approval_policy.to_core(),
|
|
response.approvals_reviewer.to_core(),
|
|
response.sandbox.to_core(),
|
|
response.cwd.clone(),
|
|
response.reasoning_effort,
|
|
)
|
|
}
|
|
|
|
fn review_target_to_app_server(
|
|
target: CoreReviewTarget,
|
|
) -> codex_app_server_protocol::ReviewTarget {
|
|
match target {
|
|
CoreReviewTarget::UncommittedChanges => {
|
|
codex_app_server_protocol::ReviewTarget::UncommittedChanges
|
|
}
|
|
CoreReviewTarget::BaseBranch { branch } => {
|
|
codex_app_server_protocol::ReviewTarget::BaseBranch { branch }
|
|
}
|
|
CoreReviewTarget::Commit { sha, title } => {
|
|
codex_app_server_protocol::ReviewTarget::Commit { sha, title }
|
|
}
|
|
CoreReviewTarget::Custom { instructions } => {
|
|
codex_app_server_protocol::ReviewTarget::Custom { instructions }
|
|
}
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "session mapping keeps explicit fields"
|
|
)]
|
|
fn session_configured_from_thread_response(
|
|
thread_id: &str,
|
|
thread_name: Option<String>,
|
|
rollout_path: Option<PathBuf>,
|
|
model: String,
|
|
model_provider_id: String,
|
|
service_tier: Option<codex_protocol::config_types::ServiceTier>,
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
|
|
sandbox_policy: SandboxPolicy,
|
|
cwd: PathBuf,
|
|
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
|
|
) -> Result<SessionConfiguredEvent, String> {
|
|
let session_id = ThreadId::from_string(thread_id)
|
|
.map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?;
|
|
|
|
Ok(SessionConfiguredEvent {
|
|
session_id,
|
|
forked_from_id: None,
|
|
thread_name,
|
|
model,
|
|
model_provider_id,
|
|
service_tier,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
cwd,
|
|
reasoning_effort,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
network_proxy: None,
|
|
rollout_path,
|
|
})
|
|
}
|
|
|
|
fn thread_initial_messages(
|
|
thread_id: &ThreadId,
|
|
turns: &[codex_app_server_protocol::Turn],
|
|
show_raw_agent_reasoning: bool,
|
|
) -> Option<Vec<EventMsg>> {
|
|
let events: Vec<EventMsg> = turns
|
|
.iter()
|
|
.flat_map(|turn| turn_initial_messages(thread_id, turn, show_raw_agent_reasoning))
|
|
.collect();
|
|
(!events.is_empty()).then_some(events)
|
|
}
|
|
|
|
fn turn_initial_messages(
|
|
thread_id: &ThreadId,
|
|
turn: &codex_app_server_protocol::Turn,
|
|
show_raw_agent_reasoning: bool,
|
|
) -> Vec<EventMsg> {
|
|
turn.items
|
|
.iter()
|
|
.cloned()
|
|
.filter_map(app_server_thread_item_to_core)
|
|
.flat_map(|item| match item {
|
|
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
|
|
TurnItem::Plan(item) => vec![EventMsg::ItemCompleted(ItemCompletedEvent {
|
|
thread_id: *thread_id,
|
|
turn_id: turn.id.clone(),
|
|
item: TurnItem::Plan(item),
|
|
})],
|
|
item => item.as_legacy_events(show_raw_agent_reasoning),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn app_server_thread_item_to_core(item: codex_app_server_protocol::ThreadItem) -> Option<TurnItem> {
|
|
match item {
|
|
codex_app_server_protocol::ThreadItem::UserMessage { id, content } => {
|
|
Some(TurnItem::UserMessage(UserMessageItem {
|
|
id,
|
|
content: content
|
|
.into_iter()
|
|
.map(codex_app_server_protocol::UserInput::into_core)
|
|
.collect(),
|
|
}))
|
|
}
|
|
codex_app_server_protocol::ThreadItem::AgentMessage { id, text, phase } => {
|
|
Some(TurnItem::AgentMessage(AgentMessageItem {
|
|
id,
|
|
content: vec![AgentMessageContent::Text { text }],
|
|
phase,
|
|
}))
|
|
}
|
|
codex_app_server_protocol::ThreadItem::Plan { id, text } => {
|
|
Some(TurnItem::Plan(PlanItem { id, text }))
|
|
}
|
|
codex_app_server_protocol::ThreadItem::Reasoning {
|
|
id,
|
|
summary,
|
|
content,
|
|
} => Some(TurnItem::Reasoning(ReasoningItem {
|
|
id,
|
|
summary_text: summary,
|
|
raw_content: content,
|
|
})),
|
|
codex_app_server_protocol::ThreadItem::WebSearch { id, query, action } => {
|
|
Some(TurnItem::WebSearch(WebSearchItem {
|
|
id,
|
|
query,
|
|
action: app_server_web_search_action_to_core(action?)?,
|
|
}))
|
|
}
|
|
codex_app_server_protocol::ThreadItem::ImageGeneration {
|
|
id,
|
|
status,
|
|
revised_prompt,
|
|
result,
|
|
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
|
|
id,
|
|
status,
|
|
revised_prompt,
|
|
result,
|
|
saved_path: None,
|
|
})),
|
|
codex_app_server_protocol::ThreadItem::ContextCompaction { id } => {
|
|
Some(TurnItem::ContextCompaction(ContextCompactionItem { id }))
|
|
}
|
|
codex_app_server_protocol::ThreadItem::CommandExecution { .. }
|
|
| codex_app_server_protocol::ThreadItem::FileChange { .. }
|
|
| codex_app_server_protocol::ThreadItem::McpToolCall { .. }
|
|
| codex_app_server_protocol::ThreadItem::DynamicToolCall { .. }
|
|
| codex_app_server_protocol::ThreadItem::CollabAgentToolCall { .. }
|
|
| codex_app_server_protocol::ThreadItem::ImageView { .. }
|
|
| codex_app_server_protocol::ThreadItem::EnteredReviewMode { .. }
|
|
| codex_app_server_protocol::ThreadItem::ExitedReviewMode { .. } => None,
|
|
}
|
|
}
|
|
|
|
fn app_server_web_search_action_to_core(
|
|
action: codex_app_server_protocol::WebSearchAction,
|
|
) -> Option<codex_protocol::models::WebSearchAction> {
|
|
match action {
|
|
codex_app_server_protocol::WebSearchAction::Search { query, queries } => {
|
|
Some(codex_protocol::models::WebSearchAction::Search { query, queries })
|
|
}
|
|
codex_app_server_protocol::WebSearchAction::OpenPage { url } => {
|
|
Some(codex_protocol::models::WebSearchAction::OpenPage { url })
|
|
}
|
|
codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => {
|
|
Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern })
|
|
}
|
|
codex_app_server_protocol::WebSearchAction::Other => {
|
|
Some(codex_protocol::models::WebSearchAction::Other)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn app_server_rate_limit_snapshots_to_core(
|
|
response: GetAccountRateLimitsResponse,
|
|
) -> Vec<RateLimitSnapshot> {
|
|
let mut snapshots = Vec::new();
|
|
snapshots.push(app_server_rate_limit_snapshot_to_core(response.rate_limits));
|
|
if let Some(by_limit_id) = response.rate_limits_by_limit_id {
|
|
snapshots.extend(
|
|
by_limit_id
|
|
.into_values()
|
|
.map(app_server_rate_limit_snapshot_to_core),
|
|
);
|
|
}
|
|
snapshots
|
|
}
|
|
|
|
pub(crate) fn app_server_rate_limit_snapshot_to_core(
|
|
snapshot: codex_app_server_protocol::RateLimitSnapshot,
|
|
) -> RateLimitSnapshot {
|
|
RateLimitSnapshot {
|
|
limit_id: snapshot.limit_id,
|
|
limit_name: snapshot.limit_name,
|
|
primary: snapshot.primary.map(app_server_rate_limit_window_to_core),
|
|
secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core),
|
|
credits: snapshot.credits.map(app_server_credits_snapshot_to_core),
|
|
plan_type: snapshot.plan_type,
|
|
}
|
|
}
|
|
|
|
fn app_server_rate_limit_window_to_core(
|
|
window: codex_app_server_protocol::RateLimitWindow,
|
|
) -> RateLimitWindow {
|
|
RateLimitWindow {
|
|
used_percent: window.used_percent as f64,
|
|
window_minutes: window.window_duration_mins,
|
|
resets_at: window.resets_at,
|
|
}
|
|
}
|
|
|
|
fn app_server_credits_snapshot_to_core(
|
|
snapshot: codex_app_server_protocol::CreditsSnapshot,
|
|
) -> CreditsSnapshot {
|
|
CreditsSnapshot {
|
|
has_credits: snapshot.has_credits,
|
|
unlimited: snapshot.unlimited,
|
|
balance: snapshot.balance,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_core::config::ConfigBuilder;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
|
|
async fn build_config(temp_dir: &TempDir) -> Config {
|
|
ConfigBuilder::default()
|
|
.codex_home(temp_dir.path().to_path_buf())
|
|
.build()
|
|
.await
|
|
.expect("config should build")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_start_params_include_cwd_for_embedded_sessions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
|
|
let params = thread_start_params_from_config(&config, ThreadParamsMode::Embedded);
|
|
|
|
assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string()));
|
|
assert_eq!(params.model_provider, Some(config.model_provider_id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_lifecycle_params_omit_local_overrides_for_remote_sessions() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let config = build_config(&temp_dir).await;
|
|
let thread_id = ThreadId::new();
|
|
|
|
let start = thread_start_params_from_config(&config, ThreadParamsMode::Remote);
|
|
let resume =
|
|
thread_resume_params_from_config(config.clone(), thread_id, ThreadParamsMode::Remote);
|
|
let fork = thread_fork_params_from_config(config, thread_id, ThreadParamsMode::Remote);
|
|
|
|
assert_eq!(start.cwd, None);
|
|
assert_eq!(resume.cwd, None);
|
|
assert_eq!(fork.cwd, None);
|
|
assert_eq!(start.model_provider, None);
|
|
assert_eq!(resume.model_provider, None);
|
|
assert_eq!(fork.model_provider, None);
|
|
}
|
|
|
|
#[test]
|
|
fn resume_response_restores_initial_messages_from_turn_items() {
|
|
let thread_id = ThreadId::new();
|
|
let response = ThreadResumeResponse {
|
|
thread: codex_app_server_protocol::Thread {
|
|
id: thread_id.to_string(),
|
|
preview: "hello".to_string(),
|
|
ephemeral: false,
|
|
model_provider: "openai".to_string(),
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
status: ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
cli_version: "0.0.0".to_string(),
|
|
source: codex_protocol::protocol::SessionSource::Cli.into(),
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: vec![Turn {
|
|
id: "turn-1".to_string(),
|
|
items: vec![
|
|
codex_app_server_protocol::ThreadItem::UserMessage {
|
|
id: "user-1".to_string(),
|
|
content: vec![codex_app_server_protocol::UserInput::Text {
|
|
text: "hello from history".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
},
|
|
codex_app_server_protocol::ThreadItem::AgentMessage {
|
|
id: "assistant-1".to_string(),
|
|
text: "assistant reply".to_string(),
|
|
phase: None,
|
|
},
|
|
],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
}],
|
|
},
|
|
model: "gpt-5.4".to_string(),
|
|
model_provider: "openai".to_string(),
|
|
service_tier: None,
|
|
cwd: PathBuf::from("/tmp/project"),
|
|
approval_policy: codex_protocol::protocol::AskForApproval::Never.into(),
|
|
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
|
|
sandbox: codex_protocol::protocol::SandboxPolicy::new_read_only_policy().into(),
|
|
reasoning_effort: None,
|
|
};
|
|
|
|
let started =
|
|
started_thread_from_resume_response(&response, /*show_raw_agent_reasoning*/ false)
|
|
.expect("resume response should map");
|
|
let initial_messages = started
|
|
.session_configured
|
|
.initial_messages
|
|
.expect("resume response should restore replay history");
|
|
|
|
assert_eq!(initial_messages.len(), 2);
|
|
match &initial_messages[0] {
|
|
EventMsg::UserMessage(event) => {
|
|
assert_eq!(event.message, "hello from history");
|
|
assert_eq!(event.images.as_ref(), Some(&Vec::new()));
|
|
assert!(event.local_images.is_empty());
|
|
assert!(event.text_elements.is_empty());
|
|
}
|
|
other => panic!("expected replayed user message, got {other:?}"),
|
|
}
|
|
match &initial_messages[1] {
|
|
EventMsg::AgentMessage(event) => {
|
|
assert_eq!(event.message, "assistant reply");
|
|
assert_eq!(event.phase, None);
|
|
}
|
|
other => panic!("expected replayed agent message, got {other:?}"),
|
|
}
|
|
}
|
|
}
|