Files
codex/codex-rs/tui_app_server/src/app/app_server_adapter.rs
T
Eric Traut 49e7dda2df Add device-code onboarding and ChatGPT token refresh to app-server TUI (#14952)
## Summary
- add device-code ChatGPT sign-in to `tui_app_server` onboarding and
reuse the existing `chatgptAuthTokens` login path
- fall back to browser login when device-code auth is unavailable on the
server
- treat `ChatgptAuthTokens` as an existing signed-in ChatGPT state
during onboarding
- add a local ChatGPT auth loader for handing local tokens to the app
server and serving refresh requests
- handle `account/chatgptAuthTokens/refresh` instead of marking it
unsupported, including workspace/account mismatch checks
- add focused coverage for onboarding success, existing auth handling,
local auth loading, and refresh request behavior

## Testing
- `cargo test -p codex-tui-app-server`
- `just fix -p codex-tui-app-server`
2026-03-17 14:12:12 -06:00

1251 lines
50 KiB
Rust

/*
This module holds the temporary adapter layer between the TUI and the app
server during the hybrid migration period.
For now, the TUI still owns its existing direct-core behavior, but startup
allocates a local in-process app server and drains its event stream. Keeping
the app-server-specific wiring here keeps that transitional logic out of the
main `app.rs` orchestration path.
As more TUI flows move onto the app-server surface directly, this adapter
should shrink and eventually disappear.
*/
use super::App;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::app_server_rate_limit_snapshot_to_core;
use crate::app_server_session::status_account_display_from_auth_mode;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
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::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AgentReasoningDeltaEvent;
use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::PlanDeltaEvent;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::ThreadNameUpdatedEvent;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use serde_json::Value;
impl App {
pub(super) async fn handle_app_server_event(
&mut self,
app_server_client: &AppServerSession,
event: AppServerEvent,
) {
match event {
AppServerEvent::Lagged { skipped } => {
tracing::warn!(
skipped,
"app-server event consumer lagged; dropping ignored events"
);
}
AppServerEvent::ServerNotification(notification) => match notification {
ServerNotification::ServerRequestResolved(notification) => {
self.pending_app_server_requests
.resolve_notification(&notification.request_id);
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget.on_rate_limit_snapshot(Some(
app_server_rate_limit_snapshot_to_core(notification.rate_limits),
));
}
ServerNotification::AccountUpdated(notification) => {
self.chat_widget.update_account_state(
status_account_display_from_auth_mode(
notification.auth_mode,
notification.plan_type,
),
notification.plan_type,
matches!(
notification.auth_mode,
Some(codex_app_server_protocol::AuthMode::Chatgpt)
| Some(codex_app_server_protocol::AuthMode::ChatgptAuthTokens)
),
);
}
notification => {
if !app_server_client.is_remote()
&& matches!(
notification,
ServerNotification::TurnCompleted(_)
| ServerNotification::ThreadRealtimeItemAdded(_)
| ServerNotification::ThreadRealtimeOutputAudioDelta(_)
| ServerNotification::ThreadRealtimeError(_)
)
{
return;
}
if let Some((thread_id, events)) =
server_notification_thread_events(notification)
{
for event in events {
if self.primary_thread_id.is_none()
|| matches!(event.msg, EventMsg::SessionConfigured(_))
&& self.primary_thread_id == Some(thread_id)
{
if let Err(err) = self.enqueue_primary_event(event).await {
tracing::warn!(
"failed to enqueue primary app-server server notification: {err}"
);
}
} else if let Err(err) =
self.enqueue_thread_event(thread_id, event).await
{
tracing::warn!(
"failed to enqueue app-server server notification for {thread_id}: {err}"
);
}
}
}
}
},
AppServerEvent::LegacyNotification(notification) => {
if let Some((thread_id, event)) = legacy_thread_event(notification.params) {
self.pending_app_server_requests.note_legacy_event(&event);
if legacy_event_is_shadowed_by_server_notification(&event.msg) {
return;
}
if self.primary_thread_id.is_none()
|| matches!(event.msg, EventMsg::SessionConfigured(_))
&& self.primary_thread_id == Some(thread_id)
{
if let Err(err) = self.enqueue_primary_event(event).await {
tracing::warn!("failed to enqueue primary app-server event: {err}");
}
} else if let Err(err) = self.enqueue_thread_event(thread_id, event).await {
tracing::warn!(
"failed to enqueue app-server thread event for {thread_id}: {err}"
);
}
}
}
AppServerEvent::ServerRequest(request) => {
if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request {
self.handle_chatgpt_auth_tokens_refresh_request(
app_server_client,
request_id,
params,
)
.await;
return;
}
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
{
tracing::warn!(
request_id = ?unsupported.request_id,
message = unsupported.message,
"rejecting unsupported app-server request"
);
self.chat_widget
.add_error_message(unsupported.message.clone());
if let Err(err) = self
.reject_app_server_request(
app_server_client,
unsupported.request_id,
unsupported.message,
)
.await
{
tracing::warn!("{err}");
}
}
}
AppServerEvent::Disconnected { message } => {
tracing::warn!("app-server event stream disconnected: {message}");
self.chat_widget.add_error_message(message.clone());
self.app_event_tx.send(AppEvent::FatalExitRequest(message));
}
}
}
async fn handle_chatgpt_auth_tokens_refresh_request(
&mut self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
params: ChatgptAuthTokensRefreshParams,
) {
let config = self.config.clone();
let result = tokio::task::spawn_blocking(move || {
resolve_chatgpt_auth_tokens_refresh_response(
&config.codex_home,
config.cli_auth_credentials_store_mode,
config.forced_chatgpt_workspace_id.as_deref(),
&params,
)
})
.await;
match result {
Ok(Ok(response)) => {
let response = serde_json::to_value(response).map_err(|err| {
format!("failed to serialize chatgpt auth refresh response: {err}")
});
match response {
Ok(response) => {
if let Err(err) = app_server_client
.resolve_server_request(request_id, response)
.await
{
tracing::warn!("failed to resolve chatgpt auth refresh request: {err}");
}
}
Err(err) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
Ok(Err(err)) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
Err(err) => {
let message = format!("chatgpt auth refresh task failed: {err}");
self.chat_widget.add_error_message(message.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, message)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
reason: String,
) -> std::result::Result<(), String> {
app_server_client
.reject_server_request(
request_id,
JSONRPCErrorError {
code: -32000,
message: reason,
data: None,
},
)
.await
.map_err(|err| format!("failed to reject app-server request: {err}"))
}
}
fn resolve_chatgpt_auth_tokens_refresh_response(
codex_home: &std::path::Path,
auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&str>,
params: &ChatgptAuthTokensRefreshParams,
) -> Result<codex_app_server_protocol::ChatgptAuthTokensRefreshResponse, String> {
let auth = load_local_chatgpt_auth(
codex_home,
auth_credentials_store_mode,
forced_chatgpt_workspace_id,
)?;
if let Some(previous_account_id) = params.previous_account_id.as_deref()
&& previous_account_id != auth.chatgpt_account_id
{
return Err(format!(
"local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`",
auth.chatgpt_account_id
));
}
Ok(auth.to_refresh_response())
}
/// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s
/// suitable for replaying into the TUI event store.
///
/// Each turn is expanded into `TurnStarted`, zero or more `ItemCompleted`,
/// and a terminal event that matches the turn's `TurnStatus`. Returns an
/// empty vec (with a warning log) if the thread ID is not a valid UUID.
pub(super) fn thread_snapshot_events(
thread: &Thread,
show_raw_agent_reasoning: bool,
) -> Vec<Event> {
let Ok(thread_id) = ThreadId::from_string(&thread.id) else {
tracing::warn!(
thread_id = %thread.id,
"ignoring app-server thread snapshot with invalid thread id"
);
return Vec::new();
};
thread
.turns
.iter()
.flat_map(|turn| turn_snapshot_events(thread_id, turn, show_raw_agent_reasoning))
.collect()
}
fn legacy_thread_event(params: Option<Value>) -> Option<(ThreadId, Event)> {
let Value::Object(mut params) = params? else {
return None;
};
let thread_id = params
.remove("conversationId")
.and_then(|value| serde_json::from_value::<String>(value).ok())
.and_then(|value| ThreadId::from_string(&value).ok());
let event = serde_json::from_value::<Event>(Value::Object(params)).ok()?;
let thread_id = thread_id.or(match &event.msg {
EventMsg::SessionConfigured(session) => Some(session.session_id),
_ => None,
})?;
Some((thread_id, event))
}
fn legacy_event_is_shadowed_by_server_notification(msg: &EventMsg) -> bool {
matches!(
msg,
EventMsg::TokenCount(_)
| EventMsg::Error(_)
| EventMsg::ThreadNameUpdated(_)
| EventMsg::TurnStarted(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::RealtimeConversationStarted(_)
| EventMsg::RealtimeConversationClosed(_)
)
}
fn server_notification_thread_events(
notification: ServerNotification,
) -> Option<(ThreadId, Vec<Event>)> {
match notification {
ServerNotification::ThreadTokenUsageUpdated(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TokenCount(TokenCountEvent {
info: Some(TokenUsageInfo {
total_token_usage: token_usage_from_app_server(
notification.token_usage.total,
),
last_token_usage: token_usage_from_app_server(
notification.token_usage.last,
),
model_context_window: notification.token_usage.model_context_window,
}),
rate_limits: None,
}),
}],
)),
ServerNotification::Error(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::Error(ErrorEvent {
message: notification.error.message,
codex_error_info: notification
.error
.codex_error_info
.and_then(app_server_codex_error_info_to_core),
}),
}],
)),
ServerNotification::ThreadNameUpdated(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
thread_name: notification.thread_name,
}),
}],
)),
ServerNotification::TurnStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: notification.turn.id,
model_context_window: None,
collaboration_mode_kind: ModeKind::default(),
}),
}],
)),
ServerNotification::TurnCompleted(notification) => {
let thread_id = ThreadId::from_string(&notification.thread_id).ok()?;
let mut events = Vec::new();
append_terminal_turn_events(
&mut events,
&notification.turn,
/*include_failed_error*/ false,
);
Some((thread_id, events))
}
ServerNotification::ItemStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ItemStarted(ItemStartedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
turn_id: notification.turn_id,
item: thread_item_to_core(&notification.item)?,
}),
}],
)),
ServerNotification::ItemCompleted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::from_string(&notification.thread_id).ok()?,
turn_id: notification.turn_id,
item: thread_item_to_core(&notification.item)?,
}),
}],
)),
ServerNotification::AgentMessageDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::PlanDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::PlanDelta(PlanDeltaEvent {
thread_id: notification.thread_id,
turn_id: notification.turn_id,
item_id: notification.item_id,
delta: notification.delta,
}),
}],
)),
ServerNotification::ReasoningSummaryTextDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::ReasoningTextDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta: notification.delta,
}),
}],
)),
ServerNotification::ThreadRealtimeStarted(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
session_id: notification.session_id,
}),
}],
)),
ServerNotification::ThreadRealtimeItemAdded(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::ConversationItemAdded(notification.item),
}),
}],
)),
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(notification.audio.into()),
}),
}],
)),
ServerNotification::ThreadRealtimeError(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error(notification.message),
}),
}],
)),
ServerNotification::ThreadRealtimeClosed(notification) => Some((
ThreadId::from_string(&notification.thread_id).ok()?,
vec![Event {
id: String::new(),
msg: EventMsg::RealtimeConversationClosed(RealtimeConversationClosedEvent {
reason: notification.reason,
}),
}],
)),
_ => None,
}
}
fn token_usage_from_app_server(
value: codex_app_server_protocol::TokenUsageBreakdown,
) -> TokenUsage {
TokenUsage {
input_tokens: value.input_tokens,
cached_input_tokens: value.cached_input_tokens,
output_tokens: value.output_tokens,
reasoning_output_tokens: value.reasoning_output_tokens,
total_tokens: value.total_tokens,
}
}
/// Expand a single `Turn` into the event sequence the TUI would have
/// observed if it had been connected for the turn's entire lifetime.
///
/// Snapshot replay keeps committed-item semantics for user / plan /
/// agent-message items, while replaying the legacy events that still
/// drive rendering for reasoning, web-search, image-generation, and
/// context-compaction history cells.
fn turn_snapshot_events(
thread_id: ThreadId,
turn: &Turn,
show_raw_agent_reasoning: bool,
) -> Vec<Event> {
let mut events = vec![Event {
id: String::new(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn.id.clone(),
model_context_window: None,
collaboration_mode_kind: ModeKind::default(),
}),
}];
for item in &turn.items {
let Some(item) = thread_item_to_core(item) else {
continue;
};
match item {
TurnItem::UserMessage(_) | TurnItem::Plan(_) | TurnItem::AgentMessage(_) => {
events.push(Event {
id: String::new(),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: turn.id.clone(),
item,
}),
});
}
TurnItem::Reasoning(_)
| TurnItem::WebSearch(_)
| TurnItem::ImageGeneration(_)
| TurnItem::ContextCompaction(_) => {
events.extend(
item.as_legacy_events(show_raw_agent_reasoning)
.into_iter()
.map(|msg| Event {
id: String::new(),
msg,
}),
);
}
}
}
append_terminal_turn_events(&mut events, turn, /*include_failed_error*/ true);
events
}
/// Append the terminal event(s) for a turn based on its `TurnStatus`.
///
/// This function is shared between the live notification bridge
/// (`TurnCompleted` handling) and the snapshot replay path so that both
/// produce identical `EventMsg` sequences for the same turn status.
///
/// - `Completed` → `TurnComplete`
/// - `Interrupted` → `TurnAborted { reason: Interrupted }`
/// - `Failed` → `Error` (if present) then `TurnComplete`
/// - `InProgress` → no events (the turn is still running)
fn append_terminal_turn_events(events: &mut Vec<Event>, turn: &Turn, include_failed_error: bool) {
match turn.status {
TurnStatus::Completed => events.push(Event {
id: String::new(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn.id.clone(),
last_agent_message: None,
}),
}),
TurnStatus::Interrupted => events.push(Event {
id: String::new(),
msg: EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(turn.id.clone()),
reason: TurnAbortReason::Interrupted,
}),
}),
TurnStatus::Failed => {
if include_failed_error && let Some(error) = &turn.error {
events.push(Event {
id: String::new(),
msg: EventMsg::Error(ErrorEvent {
message: error.message.clone(),
codex_error_info: error
.codex_error_info
.clone()
.and_then(app_server_codex_error_info_to_core),
}),
});
}
events.push(Event {
id: String::new(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn.id.clone(),
last_agent_message: None,
}),
});
}
TurnStatus::InProgress => {
// Preserve unfinished turns during snapshot replay without emitting completion events.
}
}
}
fn thread_item_to_core(item: &ThreadItem) -> Option<TurnItem> {
match item {
ThreadItem::UserMessage { id, content } => Some(TurnItem::UserMessage(UserMessageItem {
id: id.clone(),
content: content
.iter()
.cloned()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
})),
ThreadItem::AgentMessage { id, text, phase } => {
Some(TurnItem::AgentMessage(AgentMessageItem {
id: id.clone(),
content: vec![AgentMessageContent::Text { text: text.clone() }],
phase: phase.clone(),
}))
}
ThreadItem::Plan { id, text } => Some(TurnItem::Plan(PlanItem {
id: id.clone(),
text: text.clone(),
})),
ThreadItem::Reasoning {
id,
summary,
content,
} => Some(TurnItem::Reasoning(ReasoningItem {
id: id.clone(),
summary_text: summary.clone(),
raw_content: content.clone(),
})),
ThreadItem::WebSearch { id, query, action } => Some(TurnItem::WebSearch(WebSearchItem {
id: id.clone(),
query: query.clone(),
action: app_server_web_search_action_to_core(action.clone()?)?,
})),
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
id: id.clone(),
status: status.clone(),
revised_prompt: revised_prompt.clone(),
result: result.clone(),
saved_path: None,
})),
ThreadItem::ContextCompaction { id } => {
Some(TurnItem::ContextCompaction(ContextCompactionItem {
id: id.clone(),
}))
}
ThreadItem::CommandExecution { .. }
| ThreadItem::FileChange { .. }
| ThreadItem::McpToolCall { .. }
| ThreadItem::DynamicToolCall { .. }
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::ImageView { .. }
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. } => {
tracing::debug!("ignoring unsupported app-server thread item in TUI adapter");
None
}
}
}
#[cfg(test)]
mod refresh_tests {
use super::*;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthDotJson;
use codex_core::auth::save_auth;
use codex_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
fn fake_jwt(account_id: &str, plan_type: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_plan_type": plan_type,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &std::path::Path) {
let id_token = fake_jwt("workspace-1", "business");
let access_token = fake_jwt("workspace-1", "business");
save_auth(
codex_home,
&AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some("workspace-1".to_string()),
}),
last_refresh: Some(Utc::now()),
},
AuthCredentialsStoreMode::File,
)
.expect("chatgpt auth should save");
}
#[test]
fn refresh_request_uses_local_chatgpt_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let response = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
)
.expect("refresh response should resolve");
assert_eq!(response.chatgpt_account_id, "workspace-1");
assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business"));
assert!(!response.access_token.is_empty());
}
#[test]
fn refresh_request_rejects_account_mismatch() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let err = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-2".to_string()),
},
)
.expect_err("mismatched account should fail");
assert_eq!(
err,
"local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`"
);
}
}
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_codex_error_info_to_core(
value: codex_app_server_protocol::CodexErrorInfo,
) -> Option<codex_protocol::protocol::CodexErrorInfo> {
serde_json::from_value(serde_json::to_value(value).ok()?).ok()
}
#[cfg(test)]
mod tests {
use super::server_notification_thread_events;
use super::thread_snapshot_events;
use super::turn_snapshot_events;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn bridges_completed_agent_messages_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let item_id = "msg_123".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
id: item_id,
text: "Hello from your coding assistant.".to_string(),
phase: Some(MessagePhase::FinalAnswer),
},
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [event] = events.as_slice() else {
panic!("expected one bridged event");
};
assert_eq!(event.id, String::new());
let EventMsg::ItemCompleted(completed) = &event.msg else {
panic!("expected item completed event");
};
assert_eq!(
completed.thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
assert_eq!(completed.turn_id, turn_id);
match &completed.item {
TurnItem::AgentMessage(AgentMessageItem { id, content, phase }) => {
assert_eq!(id, "msg_123");
let [AgentMessageContent::Text { text }] = content.as_slice() else {
panic!("expected a single text content item");
};
assert_eq!(text, "Hello from your coding assistant.");
assert_eq!(*phase, Some(MessagePhase::FinalAnswer));
}
_ => panic!("expected bridged agent message item"),
}
}
#[test]
fn bridges_turn_completion_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.clone(),
turn: Turn {
id: turn_id.clone(),
items: Vec::new(),
status: TurnStatus::Completed,
error: None,
},
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [event] = events.as_slice() else {
panic!("expected one bridged event");
};
assert_eq!(event.id, String::new());
let EventMsg::TurnComplete(completed) = &event.msg else {
panic!("expected turn complete event");
};
assert_eq!(completed.turn_id, turn_id);
assert_eq!(completed.last_agent_message, None);
}
#[test]
fn bridges_interrupted_turn_completion_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.clone(),
turn: Turn {
id: turn_id.clone(),
items: Vec::new(),
status: TurnStatus::Interrupted,
error: None,
},
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [event] = events.as_slice() else {
panic!("expected one bridged event");
};
let EventMsg::TurnAborted(aborted) = &event.msg else {
panic!("expected turn aborted event");
};
assert_eq!(aborted.turn_id.as_deref(), Some(turn_id.as_str()));
assert_eq!(aborted.reason, TurnAbortReason::Interrupted);
}
#[test]
fn bridges_failed_turn_completion_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string();
let (actual_thread_id, events) = server_notification_thread_events(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.clone(),
turn: Turn {
id: turn_id.clone(),
items: Vec::new(),
status: TurnStatus::Failed,
error: Some(TurnError {
message: "request failed".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
}),
},
}),
)
.expect("notification should bridge");
assert_eq!(
actual_thread_id,
ThreadId::from_string(&thread_id).expect("valid thread id")
);
let [complete_event] = events.as_slice() else {
panic!("expected turn completion only");
};
let EventMsg::TurnComplete(completed) = &complete_event.msg else {
panic!("expected turn complete event");
};
assert_eq!(completed.turn_id, turn_id);
assert_eq!(completed.last_agent_message, None);
}
#[test]
fn bridges_text_deltas_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
let (_, agent_events) = server_notification_thread_events(
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
thread_id: thread_id.clone(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "Hello".to_string(),
}),
)
.expect("notification should bridge");
let [agent_event] = agent_events.as_slice() else {
panic!("expected one bridged agent delta event");
};
assert_eq!(agent_event.id, String::new());
let EventMsg::AgentMessageDelta(delta) = &agent_event.msg else {
panic!("expected bridged agent message delta");
};
assert_eq!(delta.delta, "Hello");
let (_, reasoning_events) = server_notification_thread_events(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id,
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "Thinking".to_string(),
summary_index: 0,
}),
)
.expect("notification should bridge");
let [reasoning_event] = reasoning_events.as_slice() else {
panic!("expected one bridged reasoning delta event");
};
assert_eq!(reasoning_event.id, String::new());
let EventMsg::AgentReasoningDelta(delta) = &reasoning_event.msg else {
panic!("expected bridged reasoning delta");
};
assert_eq!(delta.delta, "Thinking");
}
#[test]
fn bridges_thread_snapshot_turns_for_resume_restore() {
let thread_id = ThreadId::new();
let events = thread_snapshot_events(
&Thread {
id: thread_id.to_string(),
preview: "hello".to_string(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: ThreadStatus::Idle,
path: None,
cwd: PathBuf::from("/tmp/project"),
cli_version: "test".to_string(),
source: SessionSource::Cli.into(),
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some("restore".to_string()),
turns: vec![
Turn {
id: "turn-complete".to_string(),
items: vec![
ThreadItem::UserMessage {
id: "user-1".to_string(),
content: vec![codex_app_server_protocol::UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
},
ThreadItem::AgentMessage {
id: "assistant-1".to_string(),
text: "hi".to_string(),
phase: Some(MessagePhase::FinalAnswer),
},
],
status: TurnStatus::Completed,
error: None,
},
Turn {
id: "turn-interrupted".to_string(),
items: Vec::new(),
status: TurnStatus::Interrupted,
error: None,
},
Turn {
id: "turn-failed".to_string(),
items: Vec::new(),
status: TurnStatus::Failed,
error: Some(TurnError {
message: "request failed".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
}),
},
],
},
/*show_raw_agent_reasoning*/ false,
);
assert_eq!(events.len(), 9);
assert!(matches!(events[0].msg, EventMsg::TurnStarted(_)));
assert!(matches!(events[1].msg, EventMsg::ItemCompleted(_)));
assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_)));
assert!(matches!(events[3].msg, EventMsg::TurnComplete(_)));
assert!(matches!(events[4].msg, EventMsg::TurnStarted(_)));
let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else {
panic!("expected interrupted turn replay");
};
assert_eq!(turn_id.as_deref(), Some("turn-interrupted"));
assert_eq!(*reason, TurnAbortReason::Interrupted);
assert!(matches!(events[6].msg, EventMsg::TurnStarted(_)));
let EventMsg::Error(error) = &events[7].msg else {
panic!("expected failed turn error replay");
};
assert_eq!(error.message, "request failed");
assert_eq!(
error.codex_error_info,
Some(codex_protocol::protocol::CodexErrorInfo::Other)
);
assert!(matches!(events[8].msg, EventMsg::TurnComplete(_)));
}
#[test]
fn bridges_non_message_snapshot_items_via_legacy_events() {
let events = turn_snapshot_events(
ThreadId::new(),
&Turn {
id: "turn-complete".to_string(),
items: vec![
ThreadItem::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["Need to inspect config".to_string()],
content: vec!["hidden chain".to_string()],
},
ThreadItem::WebSearch {
id: "search-1".to_string(),
query: "ratatui stylize".to_string(),
action: Some(codex_app_server_protocol::WebSearchAction::Other),
},
ThreadItem::ImageGeneration {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("diagram".to_string()),
result: "image.png".to_string(),
},
ThreadItem::ContextCompaction {
id: "compact-1".to_string(),
},
],
status: TurnStatus::Completed,
error: None,
},
/*show_raw_agent_reasoning*/ false,
);
assert_eq!(events.len(), 6);
assert!(matches!(events[0].msg, EventMsg::TurnStarted(_)));
let EventMsg::AgentReasoning(reasoning) = &events[1].msg else {
panic!("expected reasoning replay");
};
assert_eq!(reasoning.text, "Need to inspect config");
let EventMsg::WebSearchEnd(web_search) = &events[2].msg else {
panic!("expected web search replay");
};
assert_eq!(web_search.call_id, "search-1");
assert_eq!(web_search.query, "ratatui stylize");
assert_eq!(
web_search.action,
codex_protocol::models::WebSearchAction::Other
);
let EventMsg::ImageGenerationEnd(image_generation) = &events[3].msg else {
panic!("expected image generation replay");
};
assert_eq!(image_generation.call_id, "image-1");
assert_eq!(image_generation.status, "completed");
assert_eq!(image_generation.revised_prompt.as_deref(), Some("diagram"));
assert_eq!(image_generation.result, "image.png");
assert!(matches!(events[4].msg, EventMsg::ContextCompacted(_)));
assert!(matches!(events[5].msg, EventMsg::TurnComplete(_)));
}
#[test]
fn bridges_raw_reasoning_snapshot_items_when_enabled() {
let events = turn_snapshot_events(
ThreadId::new(),
&Turn {
id: "turn-complete".to_string(),
items: vec![ThreadItem::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["Need to inspect config".to_string()],
content: vec!["hidden chain".to_string()],
}],
status: TurnStatus::Completed,
error: None,
},
/*show_raw_agent_reasoning*/ true,
);
assert_eq!(events.len(), 4);
assert!(matches!(events[0].msg, EventMsg::TurnStarted(_)));
let EventMsg::AgentReasoning(reasoning) = &events[1].msg else {
panic!("expected reasoning replay");
};
assert_eq!(reasoning.text, "Need to inspect config");
let EventMsg::AgentReasoningRawContent(raw_reasoning) = &events[2].msg else {
panic!("expected raw reasoning replay");
};
assert_eq!(raw_reasoning.text, "hidden chain");
assert!(matches!(events[3].msg, EventMsg::TurnComplete(_)));
}
}