From 4e7399c6b9878aa64051aea081278452bb6f8b62 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 22 Apr 2026 01:02:47 -0700 Subject: [PATCH 01/11] [codex-analytics] guardian review analytics events emission (#17693) ## Why Guardian approvals now run as review sessions, but Codex analytics did not have a terminal event for those reviews. That made it hard to measure approval outcomes, failure modes, Guardian session reuse, model metadata, token usage, and timing separately from the parent turn. ## What changed Adds `codex_guardian_review` analytics emission for Guardian approval reviews. The event is emitted from the Guardian review path with review identity, target item id, approval request source, a PII-minimized reviewed-action shape, terminal decision/status, failure reason, Guardian assessment fields, Guardian session metadata, token usage, and timing metadata. The reviewed-action payload intentionally omits high-risk fields such as shell commands, working directories, argv, file paths, network targets/hosts, rationale, retry reason, and permission justifications. It also classifies prompt-build failures separately from Guardian session/runtime failures so fail-closed cases are distinguishable in analytics. ## Verification - Guardian review analytics tests cover terminal success, timeout/cancel/fail-closed paths, session metadata, and token usage plumbing. - `cargo clippy -p codex-core --lib --tests -- -D warnings` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/17693). * #17696 * #17695 * __->__ #17693 --- codex-rs/analytics/src/client.rs | 11 +- codex-rs/analytics/src/events.rs | 141 +++++++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/core/src/codex_delegate.rs | 4 + .../core/src/guardian/approval_request.rs | 61 +++ codex-rs/core/src/guardian/review.rs | 385 ++++++++++++++---- codex-rs/core/src/guardian/review_session.rs | 253 +++++++++--- codex-rs/core/src/guardian/tests.rs | 10 +- codex-rs/core/src/session/mod.rs | 1 + 9 files changed, 730 insertions(+), 138 deletions(-) diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 1a4b5defe9..a3a20231f0 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -1,5 +1,6 @@ use crate::events::AppServerRpcTransport; -use crate::events::GuardianReviewEventParams; +use crate::events::GuardianReviewAnalyticsResult; +use crate::events::GuardianReviewTrackContext; use crate::events::TrackEventRequest; use crate::events::TrackEventsRequest; use crate::events::current_runtime_metadata; @@ -161,9 +162,13 @@ impl AnalyticsEventsClient { )); } - pub fn track_guardian_review(&self, input: GuardianReviewEventParams) { + pub fn track_guardian_review( + &self, + tracking: &GuardianReviewTrackContext, + result: GuardianReviewAnalyticsResult, + ) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview( - Box::new(input), + Box::new(tracking.event_params(result)), ))); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index b542f8268f..73f2886f2f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,3 +1,5 @@ +use std::time::Instant; + use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; use crate::facts::CompactionImplementation; @@ -16,6 +18,7 @@ use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; +use crate::now_unix_seconds; use codex_app_server_protocol::CodexErrorInfo; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; @@ -30,6 +33,7 @@ use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde::Serialize; #[derive(Clone, Copy, Debug, Serialize)] @@ -200,6 +204,7 @@ pub enum GuardianReviewedAction { connector_name: Option, tool_title: Option, }, + RequestPermissions {}, } #[derive(Clone, Serialize)] @@ -235,6 +240,142 @@ pub struct GuardianReviewEventParams { pub total_tokens: Option, } +pub struct GuardianReviewTrackContext { + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + review_timeout_ms: u64, + started_at: u64, + started_instant: Instant, +} + +impl GuardianReviewTrackContext { + pub fn new( + thread_id: String, + turn_id: String, + review_id: String, + target_item_id: Option, + approval_request_source: GuardianApprovalRequestSource, + reviewed_action: GuardianReviewedAction, + review_timeout_ms: u64, + ) -> Self { + Self { + thread_id, + turn_id, + review_id, + target_item_id, + approval_request_source, + reviewed_action, + review_timeout_ms, + started_at: now_unix_seconds(), + started_instant: Instant::now(), + } + } + + pub(crate) fn event_params( + &self, + result: GuardianReviewAnalyticsResult, + ) -> GuardianReviewEventParams { + GuardianReviewEventParams { + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + review_id: self.review_id.clone(), + target_item_id: self.target_item_id.clone(), + approval_request_source: self.approval_request_source, + reviewed_action: self.reviewed_action.clone(), + reviewed_action_truncated: result.reviewed_action_truncated, + decision: result.decision, + terminal_status: result.terminal_status, + failure_reason: result.failure_reason, + risk_level: result.risk_level, + user_authorization: result.user_authorization, + outcome: result.outcome, + guardian_thread_id: result.guardian_thread_id, + guardian_session_kind: result.guardian_session_kind, + guardian_model: result.guardian_model, + guardian_reasoning_effort: result.guardian_reasoning_effort, + had_prior_review_context: result.had_prior_review_context, + review_timeout_ms: self.review_timeout_ms, + // TODO(rhan-oai): plumb nested Guardian review session tool-call counts. + tool_call_count: None, + time_to_first_token_ms: result.time_to_first_token_ms, + completion_latency_ms: Some(self.started_instant.elapsed().as_millis() as u64), + started_at: self.started_at, + completed_at: Some(now_unix_seconds()), + input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens), + cached_input_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.cached_input_tokens), + output_tokens: result.token_usage.as_ref().map(|usage| usage.output_tokens), + reasoning_output_tokens: result + .token_usage + .as_ref() + .map(|usage| usage.reasoning_output_tokens), + total_tokens: result.token_usage.as_ref().map(|usage| usage.total_tokens), + } + } +} + +#[derive(Debug)] +pub struct GuardianReviewAnalyticsResult { + pub decision: GuardianReviewDecision, + pub terminal_status: GuardianReviewTerminalStatus, + pub failure_reason: Option, + pub risk_level: Option, + pub user_authorization: Option, + pub outcome: Option, + pub guardian_thread_id: Option, + pub guardian_session_kind: Option, + pub guardian_model: Option, + pub guardian_reasoning_effort: Option, + pub had_prior_review_context: Option, + pub reviewed_action_truncated: bool, + pub token_usage: Option, + pub time_to_first_token_ms: Option, +} + +impl GuardianReviewAnalyticsResult { + pub fn without_session() -> Self { + Self { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: None, + risk_level: None, + user_authorization: None, + outcome: None, + guardian_thread_id: None, + guardian_session_kind: None, + guardian_model: None, + guardian_reasoning_effort: None, + had_prior_review_context: None, + reviewed_action_truncated: false, + token_usage: None, + time_to_first_token_ms: None, + } + } + + pub fn from_session( + guardian_thread_id: String, + guardian_session_kind: GuardianReviewSessionKind, + guardian_model: String, + guardian_reasoning_effort: Option, + had_prior_review_context: bool, + ) -> Self { + Self { + guardian_thread_id: Some(guardian_thread_id), + guardian_session_kind: Some(guardian_session_kind), + guardian_model: Some(guardian_model), + guardian_reasoning_effort, + had_prior_review_context: Some(had_prior_review_context), + ..Self::without_session() + } + } +} + #[derive(Serialize)] pub(crate) struct GuardianReviewEventPayload { pub(crate) app_server_client: CodexAppServerClientMetadata, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 5c4cdfac79..ed0f1036ca 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -9,11 +9,13 @@ use std::time::UNIX_EPOCH; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; +pub use events::GuardianReviewAnalyticsResult; pub use events::GuardianReviewDecision; pub use events::GuardianReviewEventParams; pub use events::GuardianReviewFailureReason; pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; +pub use events::GuardianReviewTrackContext; pub use events::GuardianReviewedAction; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 858154eb77..22d1509fe1 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_channel::Receiver; use async_channel::Sender; +use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::Event; @@ -471,6 +472,7 @@ async fn handle_exec_approval( justification: None, }, reason, + GuardianApprovalRequestSource::DelegatedSubagent, review_cancel.clone(), ); await_approval_with_cancel( @@ -573,6 +575,7 @@ async fn handle_patch_approval( patch, }, reason.clone(), + GuardianApprovalRequestSource::DelegatedSubagent, review_cancel.clone(), ); Some( @@ -689,6 +692,7 @@ async fn maybe_auto_review_mcp_request_user_input( new_guardian_review_id(), build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()), /*retry_reason*/ None, + GuardianApprovalRequestSource::DelegatedSubagent, review_cancel.clone(), ); let decision = await_approval_with_cancel( diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 7478377381..471e054429 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -1,5 +1,6 @@ use std::path::Path; +use codex_analytics::GuardianReviewedAction; use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -390,6 +391,66 @@ pub(crate) fn guardian_assessment_action( } } +pub(crate) fn guardian_reviewed_action( + request: &GuardianApprovalRequest, +) -> GuardianReviewedAction { + match request { + GuardianApprovalRequest::Shell { + sandbox_permissions, + additional_permissions, + .. + } => GuardianReviewedAction::Shell { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ExecCommand { + sandbox_permissions, + additional_permissions, + tty, + .. + } => GuardianReviewedAction::UnifiedExec { + sandbox_permissions: *sandbox_permissions, + additional_permissions: additional_permissions.clone(), + tty: *tty, + }, + #[cfg(unix)] + GuardianApprovalRequest::Execve { + source, + program, + additional_permissions, + .. + } => GuardianReviewedAction::Execve { + source: *source, + program: program.clone(), + additional_permissions: additional_permissions.clone(), + }, + GuardianApprovalRequest::ApplyPatch { .. } => GuardianReviewedAction::ApplyPatch {}, + GuardianApprovalRequest::NetworkAccess { protocol, port, .. } => { + GuardianReviewedAction::NetworkAccess { + protocol: *protocol, + port: *port, + } + } + GuardianApprovalRequest::McpToolCall { + server, + tool_name, + connector_id, + connector_name, + tool_title, + .. + } => GuardianReviewedAction::McpToolCall { + server: server.clone(), + tool_name: tool_name.clone(), + connector_id: connector_id.clone(), + connector_name: connector_name.clone(), + tool_title: tool_title.clone(), + }, + GuardianApprovalRequest::RequestPermissions { .. } => { + GuardianReviewedAction::RequestPermissions {} + } + } +} + pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) -> Option<&str> { match request { GuardianApprovalRequest::Shell { id, .. } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 486c079f31..ce01ac970e 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -1,5 +1,12 @@ use std::sync::Arc; +use codex_analytics::GuardianApprovalRequestSource; +use codex_analytics::GuardianReviewAnalyticsResult; +use codex_analytics::GuardianReviewDecision; +use codex_analytics::GuardianReviewFailureReason; +use codex_analytics::GuardianReviewTerminalStatus; +use codex_analytics::GuardianReviewTrackContext; +use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -17,6 +24,7 @@ use tokio_util::sync::CancellationToken; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use super::GUARDIAN_REVIEW_TIMEOUT; use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianAssessment; @@ -25,6 +33,7 @@ use super::GuardianRejection; use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; +use super::approval_request::guardian_reviewed_action; use super::prompt::guardian_output_schema; use super::prompt::parse_guardian_assessment; use super::review_session::GuardianReviewSessionOutcome; @@ -76,9 +85,47 @@ pub(crate) fn guardian_timeout_message() -> String { #[derive(Debug)] pub(super) enum GuardianReviewOutcome { - Completed(anyhow::Result), - TimedOut, - Aborted, + Completed(GuardianAssessment), + Error(GuardianReviewError), +} + +#[derive(Debug)] +pub(super) enum GuardianReviewError { + PromptBuild { message: String }, + Session { message: String }, + Parse { message: String }, + Timeout, + Cancelled, +} + +impl GuardianReviewError { + fn prompt_build(err: anyhow::Error) -> Self { + Self::PromptBuild { + message: err.to_string(), + } + } + + fn session(err: anyhow::Error) -> Self { + Self::Session { + message: err.to_string(), + } + } + + fn parse(err: anyhow::Error) -> Self { + Self::Parse { + message: err.to_string(), + } + } + + fn failure_reason(&self) -> GuardianReviewFailureReason { + match self { + Self::PromptBuild { .. } => GuardianReviewFailureReason::PromptBuildError, + Self::Session { .. } => GuardianReviewFailureReason::SessionError, + Self::Parse { .. } => GuardianReviewFailureReason::ParseError, + Self::Timeout => GuardianReviewFailureReason::Timeout, + Self::Cancelled => GuardianReviewFailureReason::Cancelled, + } + } } fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str { @@ -110,6 +157,21 @@ pub(crate) fn is_guardian_reviewer_source( ) } +fn track_guardian_review( + session: &Session, + turn: &TurnContext, + tracking: &GuardianReviewTrackContext, + result: GuardianReviewAnalyticsResult, +) { + if !turn.config.features.enabled(Feature::GeneralAnalytics) { + return; + } + session + .services + .analytics_events_client + .track_guardian_review(tracking, result); +} + /// This function always fails closed: timeouts, review-session failures, and /// parse failures all block execution, but timeouts are still surfaced to the /// caller as distinct from explicit guardian denials. @@ -119,11 +181,21 @@ async fn run_guardian_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, external_cancel: Option, ) -> ReviewDecision { let target_item_id = guardian_request_target_item_id(&request).map(str::to_string); let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string(); let action_summary = guardian_assessment_action(&request); + let review_tracking = GuardianReviewTrackContext::new( + session.conversation_id.to_string(), + assessment_turn_id.clone(), + review_id.clone(), + target_item_id.clone(), + approval_request_source, + guardian_reviewed_action(&request), + GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64, + ); session .send_event( turn.as_ref(), @@ -145,6 +217,17 @@ async fn run_guardian_review( .as_ref() .is_some_and(CancellationToken::is_cancelled) { + track_guardian_review( + session.as_ref(), + turn.as_ref(), + &review_tracking, + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(GuardianReviewFailureReason::Cancelled), + ..GuardianReviewAnalyticsResult::without_session() + }, + ); session .send_event( turn.as_ref(), @@ -166,73 +249,146 @@ async fn run_guardian_review( let schema = guardian_output_schema(); let terminal_action = action_summary.clone(); - let outcome = Box::pin(run_guardian_review_session( + let (outcome, analytics_result) = Box::pin(run_guardian_review_session( session.clone(), turn.clone(), request, - retry_reason, + retry_reason.clone(), schema, external_cancel, )) .await; let assessment = match outcome { - GuardianReviewOutcome::Completed(Ok(assessment)) => assessment, - GuardianReviewOutcome::Completed(Err(err)) => GuardianAssessment { - risk_level: GuardianRiskLevel::High, - user_authorization: GuardianUserAuthorization::Unknown, - outcome: GuardianAssessmentOutcome::Deny, - rationale: format!("Automatic approval review failed: {err}"), + GuardianReviewOutcome::Completed(assessment) => { + let approved = matches!(assessment.outcome, GuardianAssessmentOutcome::Allow); + track_guardian_review( + session.as_ref(), + turn.as_ref(), + &review_tracking, + GuardianReviewAnalyticsResult { + decision: if approved { + GuardianReviewDecision::Approved + } else { + GuardianReviewDecision::Denied + }, + terminal_status: if approved { + GuardianReviewTerminalStatus::Approved + } else { + GuardianReviewTerminalStatus::Denied + }, + failure_reason: None, + risk_level: Some(assessment.risk_level), + user_authorization: Some(assessment.user_authorization), + outcome: Some(assessment.outcome), + ..analytics_result + }, + ); + assessment + } + GuardianReviewOutcome::Error(error) => match error { + GuardianReviewError::Timeout => { + let rationale = + "Automatic approval review timed out while evaluating the requested approval." + .to_string(); + track_guardian_review( + session.as_ref(), + turn.as_ref(), + &review_tracking, + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::TimedOut, + failure_reason: Some(error.failure_reason()), + ..analytics_result + }, + ); + session + .send_event( + turn.as_ref(), + EventMsg::Warning(WarningEvent { + message: rationale.clone(), + }), + ) + .await; + session + .send_event( + turn.as_ref(), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: review_id, + target_item_id, + turn_id: assessment_turn_id, + status: GuardianAssessmentStatus::TimedOut, + risk_level: None, + user_authorization: None, + rationale: Some(rationale), + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action: terminal_action, + }), + ) + .await; + return ReviewDecision::TimedOut; + } + GuardianReviewError::Cancelled => { + track_guardian_review( + session.as_ref(), + turn.as_ref(), + &review_tracking, + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Aborted, + terminal_status: GuardianReviewTerminalStatus::Aborted, + failure_reason: Some(error.failure_reason()), + ..analytics_result + }, + ); + session + .send_event( + turn.as_ref(), + EventMsg::GuardianAssessment(GuardianAssessmentEvent { + id: review_id, + target_item_id, + turn_id: assessment_turn_id, + status: GuardianAssessmentStatus::Aborted, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: Some(GuardianAssessmentDecisionSource::Agent), + action: action_summary, + }), + ) + .await; + return ReviewDecision::Abort; + } + GuardianReviewError::PromptBuild { .. } + | GuardianReviewError::Session { .. } + | GuardianReviewError::Parse { .. } => { + let message = match &error { + GuardianReviewError::PromptBuild { message } + | GuardianReviewError::Session { message } + | GuardianReviewError::Parse { message } => message, + GuardianReviewError::Timeout | GuardianReviewError::Cancelled => { + "guardian review failed" + } + }; + let rationale = format!("Automatic approval review failed: {message}"); + track_guardian_review( + session.as_ref(), + turn.as_ref(), + &review_tracking, + GuardianReviewAnalyticsResult { + decision: GuardianReviewDecision::Denied, + terminal_status: GuardianReviewTerminalStatus::FailedClosed, + failure_reason: Some(error.failure_reason()), + ..analytics_result + }, + ); + GuardianAssessment { + risk_level: GuardianRiskLevel::High, + user_authorization: GuardianUserAuthorization::Unknown, + outcome: GuardianAssessmentOutcome::Deny, + rationale, + } + } }, - GuardianReviewOutcome::TimedOut => { - let rationale = - "Automatic approval review timed out while evaluating the requested approval." - .to_string(); - session - .send_event( - turn.as_ref(), - EventMsg::Warning(WarningEvent { - message: rationale.clone(), - }), - ) - .await; - session - .send_event( - turn.as_ref(), - EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: review_id, - target_item_id, - turn_id: assessment_turn_id, - status: GuardianAssessmentStatus::TimedOut, - risk_level: None, - user_authorization: None, - rationale: Some(rationale), - decision_source: Some(GuardianAssessmentDecisionSource::Agent), - action: terminal_action, - }), - ) - .await; - return ReviewDecision::TimedOut; - } - GuardianReviewOutcome::Aborted => { - session - .send_event( - turn.as_ref(), - EventMsg::GuardianAssessment(GuardianAssessmentEvent { - id: review_id, - target_item_id, - turn_id: assessment_turn_id, - status: GuardianAssessmentStatus::Aborted, - risk_level: None, - user_authorization: None, - rationale: None, - decision_source: Some(GuardianAssessmentDecisionSource::Agent), - action: action_summary, - }), - ) - .await; - return ReviewDecision::Abort; - } }; let approved = match assessment.outcome { @@ -314,6 +470,7 @@ pub(crate) async fn review_approval_request( review_id, request, retry_reason, + GuardianApprovalRequestSource::MainTurn, /*external_cancel*/ None, )) .await @@ -325,16 +482,18 @@ pub(crate) async fn review_approval_request_with_cancel( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, cancel_token: CancellationToken, ) -> ReviewDecision { - Box::pin(run_guardian_review( + run_guardian_review( Arc::clone(session), Arc::clone(turn), review_id, request, retry_reason, + approval_request_source, Some(cancel_token), - )) + ) .await } @@ -344,6 +503,7 @@ pub(crate) fn spawn_approval_request_review( review_id: String, request: GuardianApprovalRequest, retry_reason: Option, + approval_request_source: GuardianApprovalRequestSource, cancel_token: CancellationToken, ) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); @@ -361,6 +521,7 @@ pub(crate) fn spawn_approval_request_review( review_id, request, retry_reason, + approval_request_source, cancel_token, )); let _ = tx.send(decision); @@ -389,11 +550,16 @@ pub(super) async fn run_guardian_review_session( retry_reason: Option, schema: serde_json::Value, external_cancel: Option, -) -> GuardianReviewOutcome { +) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { let live_network_config = match session.services.network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return ( + GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), + GuardianReviewAnalyticsResult::without_session(), + ); + } }, None => None, }; @@ -443,10 +609,15 @@ pub(super) async fn run_guardian_review_session( ); let guardian_config = match guardian_config { Ok(config) => config, - Err(err) => return GuardianReviewOutcome::Completed(Err(err)), + Err(err) => { + return ( + GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), + GuardianReviewAnalyticsResult::without_session(), + ); + } }; - match Box::pin( + let (session_outcome, session_analytics_result) = Box::pin( session .guardian_review_session .run_review(GuardianReviewSessionParams { @@ -463,17 +634,75 @@ pub(super) async fn run_guardian_review_session( external_cancel, }), ) - .await - { - GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => { - GuardianReviewOutcome::Completed(parse_guardian_assessment( - last_agent_message.as_deref(), - )) - } - GuardianReviewSessionOutcome::Completed(Err(err)) => { - GuardianReviewOutcome::Completed(Err(err)) - } - GuardianReviewSessionOutcome::TimedOut => GuardianReviewOutcome::TimedOut, - GuardianReviewSessionOutcome::Aborted => GuardianReviewOutcome::Aborted, + .await; + + match session_outcome { + GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message + { + Some(last_agent_message) => { + match parse_guardian_assessment(Some(&last_agent_message)) { + Ok(assessment) => ( + GuardianReviewOutcome::Completed(assessment), + session_analytics_result, + ), + Err(err) => ( + GuardianReviewOutcome::Error(GuardianReviewError::parse(err)), + session_analytics_result, + ), + } + } + None => ( + GuardianReviewOutcome::Error(GuardianReviewError::session(anyhow::anyhow!( + "guardian review completed without an assessment payload" + ))), + session_analytics_result, + ), + }, + GuardianReviewSessionOutcome::Completed(Err(err)) => ( + GuardianReviewOutcome::Error(GuardianReviewError::session(err)), + session_analytics_result, + ), + GuardianReviewSessionOutcome::PromptBuildFailed(err) => ( + GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), + session_analytics_result, + ), + GuardianReviewSessionOutcome::SessionFailed(err) => ( + GuardianReviewOutcome::Error(GuardianReviewError::session(err)), + session_analytics_result, + ), + GuardianReviewSessionOutcome::TimedOut => ( + GuardianReviewOutcome::Error(GuardianReviewError::Timeout), + session_analytics_result, + ), + GuardianReviewSessionOutcome::Aborted => ( + GuardianReviewOutcome::Error(GuardianReviewError::Cancelled), + session_analytics_result, + ), + } +} + +#[cfg(test)] +mod review_tests { + use super::*; + + #[test] + fn guardian_review_error_reason_distinguishes_error_kinds() { + let parse_error = GuardianReviewError::parse(anyhow::anyhow!("bad guardian JSON")); + let prompt_error = GuardianReviewError::prompt_build(anyhow::anyhow!("bad prompt/config")); + let session_error = + GuardianReviewError::session(anyhow::anyhow!("guardian runtime failed")); + + assert!(matches!( + parse_error.failure_reason(), + GuardianReviewFailureReason::ParseError + )); + assert!(matches!( + prompt_error.failure_reason(), + GuardianReviewFailureReason::PromptBuildError + )); + assert!(matches!( + session_error.failure_reason(), + GuardianReviewFailureReason::SessionError + )); } } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index dc5d6e3108..e68024dbf0 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -5,6 +5,8 @@ use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; +use codex_analytics::GuardianReviewAnalyticsResult; +use codex_analytics::GuardianReviewSessionKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; @@ -16,6 +18,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::TokenUsage; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::Semaphore; @@ -52,6 +55,8 @@ const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug)] pub(crate) enum GuardianReviewSessionOutcome { Completed(anyhow::Result>), + PromptBuildFailed(anyhow::Error), + SessionFailed(anyhow::Error), TimedOut, Aborted, } @@ -95,6 +100,21 @@ struct GuardianReviewState { last_committed_fork_snapshot: Option, } +fn had_prior_review_context(prompt_mode: &GuardianPromptMode) -> bool { + matches!(prompt_mode, GuardianPromptMode::Delta { .. }) +} + +fn token_usage_delta(start: &TokenUsage, end: &TokenUsage) -> TokenUsage { + TokenUsage { + input_tokens: (end.input_tokens - start.input_tokens).max(0), + cached_input_tokens: (end.cached_input_tokens - start.cached_input_tokens).max(0), + output_tokens: (end.output_tokens - start.output_tokens).max(0), + reasoning_output_tokens: (end.reasoning_output_tokens - start.reasoning_output_tokens) + .max(0), + total_tokens: (end.total_tokens - start.total_tokens).max(0), + } +} + struct EphemeralReviewCleanup { state: Arc>, review_session: Option>, @@ -262,13 +282,14 @@ impl GuardianReviewSessionManager { clippy::await_holding_invalid_type, reason = "review session selection and trunk spawning must stay serialized" )] - pub(crate) async fn run_review( + pub(super) async fn run_review( &self, params: GuardianReviewSessionParams, - ) -> GuardianReviewSessionOutcome { + ) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) { let deadline = tokio::time::Instant::now() + GUARDIAN_REVIEW_TIMEOUT; let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); let mut stale_trunk_to_shutdown = None; + let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( deadline, params.external_cancel.as_ref(), @@ -302,16 +323,22 @@ impl GuardianReviewSessionManager { { Ok(Ok(review_session)) => Arc::new(review_session), Ok(Err(err)) => { - return GuardianReviewSessionOutcome::Completed(Err(err)); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + GuardianReviewAnalyticsResult::without_session(), + ); + } + Err(outcome) => { + return (outcome, GuardianReviewAnalyticsResult::without_session()); } - Err(outcome) => return outcome, }; state.trunk = Some(Arc::clone(&review_session)); + spawned_trunk = true; } state.trunk.as_ref().cloned() } - Err(outcome) => return outcome, + Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()), }; if let Some(review_session) = stale_trunk_to_shutdown { @@ -319,9 +346,12 @@ impl GuardianReviewSessionManager { } let Some(trunk) = trunk_candidate else { - return GuardianReviewSessionOutcome::Completed(Err(anyhow!( - "guardian review session was not available after spawn" - ))); + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!( + "guardian review session was not available after spawn" + ))), + GuardianReviewAnalyticsResult::without_session(), + ); }; if trunk.reuse_key != next_reuse_key { @@ -347,20 +377,30 @@ impl GuardianReviewSessionManager { } }; - let (outcome, keep_review_session) = - Box::pin(run_review_on_session(trunk.as_ref(), ¶ms, deadline)).await; + let guardian_session_kind = if spawned_trunk { + GuardianReviewSessionKind::TrunkNew + } else { + GuardianReviewSessionKind::TrunkReused + }; + let (outcome, keep_review_session, analytics_result) = Box::pin(run_review_on_session( + trunk.as_ref(), + ¶ms, + guardian_session_kind, + deadline, + )) + .await; if keep_review_session && matches!(outcome, GuardianReviewSessionOutcome::Completed(_)) { trunk.refresh_last_committed_fork_snapshot().await; } drop(trunk_guard); if keep_review_session { - outcome + (outcome, analytics_result) } else { if let Some(review_session) = self.remove_trunk_if_current(&trunk).await { review_session.shutdown_in_background(); } - outcome + (outcome, analytics_result) } } @@ -457,7 +497,7 @@ impl GuardianReviewSessionManager { reuse_key: GuardianReviewSessionReuseKey, deadline: tokio::time::Instant, fork_snapshot: Option, - ) -> GuardianReviewSessionOutcome { + ) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) { let spawn_cancel_token = CancellationToken::new(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; @@ -476,17 +516,23 @@ impl GuardianReviewSessionManager { .await { Ok(Ok(review_session)) => Arc::new(review_session), - Ok(Err(err)) => return GuardianReviewSessionOutcome::Completed(Err(err)), - Err(outcome) => return outcome, + Ok(Err(err)) => { + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err), + GuardianReviewAnalyticsResult::without_session(), + ); + } + Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()), }; self.register_active_ephemeral(Arc::clone(&review_session)) .await; let mut cleanup = EphemeralReviewCleanup::new(Arc::clone(&self.state), Arc::clone(&review_session)); - let (outcome, _) = Box::pin(run_review_on_session( + let (outcome, _, analytics_result) = Box::pin(run_review_on_session( review_session.as_ref(), ¶ms, + GuardianReviewSessionKind::EphemeralForked, deadline, )) .await; @@ -494,7 +540,7 @@ impl GuardianReviewSessionManager { cleanup.disarm(); review_session.shutdown_in_background(); } - outcome + (outcome, analytics_result) } } @@ -541,8 +587,13 @@ async fn spawn_guardian_review_session( async fn run_review_on_session( review_session: &GuardianReviewSession, params: &GuardianReviewSessionParams, + guardian_session_kind: GuardianReviewSessionKind, deadline: tokio::time::Instant, -) -> (GuardianReviewSessionOutcome, bool) { +) -> ( + GuardianReviewSessionOutcome, + bool, + GuardianReviewAnalyticsResult, +) { let (send_followup_reminder, prompt_mode) = { let state = review_session.state.lock().await; @@ -557,11 +608,34 @@ async fn run_review_on_session( (send_followup_reminder, prompt_mode) }; + let model_info = params + .parent_session + .services + .models_manager + .get_model_info( + params.model.as_str(), + ¶ms.spawn_config.to_models_manager_config(), + ) + .await; + let guardian_reasoning_effort = if model_info.supports_reasoning_summaries { + params + .reasoning_effort + .or(model_info.default_reasoning_level) + } else { + None + }; + let mut analytics_result = GuardianReviewAnalyticsResult::from_session( + review_session.codex.session.conversation_id.to_string(), + guardian_session_kind, + params.model.clone(), + guardian_reasoning_effort.map(|effort| effort.to_string()), + had_prior_review_context(&prompt_mode), + ); if send_followup_reminder { append_guardian_followup_reminder(review_session).await; } - let submit_result = run_before_review_deadline( + let prompt_items = run_before_review_deadline( deadline, params.external_cancel.as_ref(), Box::pin(async { @@ -574,56 +648,86 @@ async fn run_review_on_session( ) .await; - let prompt_items = build_guardian_prompt_items( + build_guardian_prompt_items( params.parent_session.as_ref(), params.retry_reason.clone(), params.request.clone(), prompt_mode, ) - .await?; - - review_session - .codex - .submit(Op::UserTurn { - environments: None, - items: prompt_items.items, - cwd: params.parent_turn.cwd.to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - model: params.model.clone(), - effort: params.reasoning_effort, - summary: Some(params.reasoning_summary), - service_tier: None, - final_output_json_schema: Some(params.schema.clone()), - collaboration_mode: None, - personality: params.personality, - }) - .await?; - - Ok::(prompt_items.transcript_cursor) + .await }), ) .await; - let submit_result = match submit_result { - Ok(submit_result) => submit_result, - Err(outcome) => return (outcome, false), + let prompt_items = match prompt_items { + Ok(prompt_items) => prompt_items, + Err(outcome) => return (outcome, false, analytics_result), }; - let transcript_cursor = match submit_result { - Ok(transcript_cursor) => transcript_cursor, + let prompt_items = match prompt_items { + Ok(prompt_items) => prompt_items, Err(err) => { - return (GuardianReviewSessionOutcome::Completed(Err(err)), false); + return ( + GuardianReviewSessionOutcome::PromptBuildFailed(err.into()), + false, + analytics_result, + ); } }; + let transcript_cursor = prompt_items.transcript_cursor; + let token_usage_at_review_start = review_session + .codex + .session + .total_token_usage() + .await + .unwrap_or_default(); + + let submit_result = run_before_review_deadline( + deadline, + params.external_cancel.as_ref(), + Box::pin(review_session.codex.submit(Op::UserTurn { + environments: None, + items: prompt_items.items, + cwd: params.parent_turn.cwd.to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: params.model.clone(), + effort: params.reasoning_effort, + summary: Some(params.reasoning_summary), + service_tier: None, + final_output_json_schema: Some(params.schema.clone()), + collaboration_mode: None, + personality: params.personality, + })), + ) + .await; + match submit_result { + Ok(Ok(_)) => {} + Ok(Err(err)) => { + return ( + GuardianReviewSessionOutcome::SessionFailed(err.into()), + false, + analytics_result, + ); + } + Err(outcome) => return (outcome, false, analytics_result), + } let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { + if outcome.2 + && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await + { + analytics_result.token_usage = Some(token_usage_delta( + &token_usage_at_review_start, + &total_token_usage, + )); + } let mut state = review_session.state.lock().await; state.prior_review_count = state.prior_review_count.saturating_add(1); state.last_reviewed_transcript_cursor = Some(transcript_cursor); } - outcome + (outcome.0, outcome.1, analytics_result) } async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) { @@ -651,7 +755,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, -) -> (GuardianReviewSessionOutcome, bool) { +) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); let mut last_error_message: Option = None; @@ -660,7 +764,7 @@ async fn wait_for_guardian_review( tokio::select! { _ = &mut timeout => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::TimedOut, keep_review_session); + return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false); } _ = async { if let Some(cancel_token) = external_cancel { @@ -670,7 +774,7 @@ async fn wait_for_guardian_review( } } => { let keep_review_session = interrupt_and_drain_turn(&review_session.codex).await.is_ok(); - return (GuardianReviewSessionOutcome::Aborted, keep_review_session); + return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false); } event = review_session.codex.next_event() => { match event { @@ -682,18 +786,20 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), true, + true, ); } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, + true, ); } EventMsg::Error(error) => { last_error_message = Some(error.message); } EventMsg::TurnAborted(_) => { - return (GuardianReviewSessionOutcome::Aborted, true); + return (GuardianReviewSessionOutcome::Aborted, true, false); } _ => {} }, @@ -701,6 +807,7 @@ async fn wait_for_guardian_review( return ( GuardianReviewSessionOutcome::Completed(Err(err.into())), false, + false, ); } } @@ -999,4 +1106,44 @@ mod tests { assert_eq!(outcome.unwrap(), 42); assert!(!cancel_token.is_cancelled()); } + + #[test] + fn had_prior_review_context_tracks_prompt_mode() { + assert!(!had_prior_review_context(&GuardianPromptMode::Full)); + assert!(had_prior_review_context(&GuardianPromptMode::Delta { + cursor: GuardianTranscriptCursor { + parent_history_version: 7, + transcript_entry_count: 42, + } + })); + } + + #[test] + fn token_usage_delta_never_reports_negative_usage() { + let start = TokenUsage { + input_tokens: 10, + cached_input_tokens: 8, + output_tokens: 6, + reasoning_output_tokens: 4, + total_tokens: 28, + }; + let end = TokenUsage { + input_tokens: 15, + cached_input_tokens: 7, + output_tokens: 10, + reasoning_output_tokens: 2, + total_tokens: 34, + }; + + assert_eq!( + token_usage_delta(&start, &end), + TokenUsage { + input_tokens: 5, + cached_input_tokens: 0, + output_tokens: 4, + reasoning_output_tokens: 0, + total_tokens: 6, + } + ); + } } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 167cf9a155..9ce265e91d 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -15,6 +15,7 @@ use crate::config_loader::Sourced; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::test_support; +use codex_analytics::GuardianApprovalRequestSource; use codex_config::config_toml::ConfigToml; use codex_config::types::McpServerConfig; use codex_exec_server::LOCAL_FS; @@ -707,6 +708,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { .to_string(), }, /*retry_reason*/ None, + GuardianApprovalRequestSource::MainTurn, cancel_token, ) .await; @@ -994,7 +996,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() /*external_cancel*/ None, ) .await; - let GuardianReviewOutcome::Completed(Ok(assessment)) = outcome else { + let (GuardianReviewOutcome::Completed(assessment), _) = outcome else { panic!("expected guardian assessment"); }; assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); @@ -1233,13 +1235,13 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ) .await; - let GuardianReviewOutcome::Completed(Ok(first_assessment)) = first_outcome else { + let (GuardianReviewOutcome::Completed(first_assessment), _) = first_outcome else { panic!("expected first guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(second_assessment)) = second_outcome else { + let (GuardianReviewOutcome::Completed(second_assessment), _) = second_outcome else { panic!("expected second guardian assessment"); }; - let GuardianReviewOutcome::Completed(Ok(third_assessment)) = third_outcome else { + let (GuardianReviewOutcome::Completed(third_assessment), _) = third_outcome else { panic!("expected third guardian assessment"); }; assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d45357e3e5..2efb59cdfa 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1919,6 +1919,7 @@ impl Session { review_id, request, /*retry_reason*/ None, + codex_analytics::GuardianApprovalRequestSource::MainTurn, cancellation_token.clone(), ); let decision = tokio::select! { From 37aadeaa13fc9b8b2f9cd93f56786e236bf9f8d1 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 22 Apr 2026 01:35:29 -0700 Subject: [PATCH 02/11] [codex-analytics] guardian review truncation (#17695) ## Why The Guardian review event needs to report whether the action shown to Guardian was truncated. That field should come from the same truncation path used to build the Guardian prompt, rather than being inferred after the fact. ## What changed Plumbs truncation metadata through Guardian action formatting, prompt construction, review session execution, and analytics emission. `guardian_truncate_text` now reports both the rendered text and whether it inserted the truncation marker, and `reviewed_action_truncated` is set from that prompt-building result. This keeps the analytics field aligned with the model-visible reviewed action while preserving the existing Guardian prompt behavior. ## Verification - Guardian truncation tests cover both truncated and non-truncated action payloads. - Guardian review tests assert the review session metadata and truncation field are propagated. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/17695). * #17696 * __->__ #17695 * #17693 * #18278 * #18953 --- .../core/src/guardian/approval_request.rs | 62 ++++++---- codex-rs/core/src/guardian/prompt.rs | 16 +-- codex-rs/core/src/guardian/review_session.rs | 2 + codex-rs/core/src/guardian/tests.rs | 106 ++++++++++++++++-- 4 files changed, 149 insertions(+), 37 deletions(-) diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 471e054429..989c6ee119 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -184,32 +184,49 @@ fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static } } -fn truncate_guardian_action_value(value: Value) -> Value { +fn truncate_guardian_action_value(value: Value) -> (Value, bool) { match value { - Value::String(text) => Value::String(guardian_truncate_text( - &text, - GUARDIAN_MAX_ACTION_STRING_TOKENS, - )), - Value::Array(values) => Value::Array( - values + Value::String(text) => { + let (text, truncated) = + guardian_truncate_text(&text, GUARDIAN_MAX_ACTION_STRING_TOKENS); + (Value::String(text), truncated) + } + Value::Array(values) => { + let mut truncated = false; + let values = values .into_iter() - .map(truncate_guardian_action_value) - .collect::>(), - ), + .map(|value| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + value + }) + .collect::>(); + (Value::Array(values), truncated) + } Value::Object(values) => { let mut entries = values.into_iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - Value::Object( - entries - .into_iter() - .map(|(key, value)| (key, truncate_guardian_action_value(value))) - .collect(), - ) + let mut truncated = false; + let values = entries + .into_iter() + .map(|(key, value)| { + let (value, value_truncated) = truncate_guardian_action_value(value); + truncated |= value_truncated; + (key, value) + }) + .collect(); + (Value::Object(values), truncated) } - other => other, + other => (other, false), } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FormattedGuardianAction { + pub(crate) text: String, + pub(crate) truncated: bool, +} + pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { @@ -482,8 +499,11 @@ pub(crate) fn guardian_request_turn_id<'a>( pub(crate) fn format_guardian_action_pretty( action: &GuardianApprovalRequest, -) -> serde_json::Result { - let mut value = guardian_approval_request_to_json(action)?; - value = truncate_guardian_action_value(value); - serde_json::to_string_pretty(&value) +) -> serde_json::Result { + let value = guardian_approval_request_to_json(action)?; + let (value, truncated) = truncate_guardian_action_value(value); + Ok(FormattedGuardianAction { + text: serde_json::to_string_pretty(&value)?, + truncated, + }) } diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 1a716f25b9..adf9dd8d70 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -59,6 +59,7 @@ impl GuardianTranscriptEntryKind { pub(crate) struct GuardianPromptItems { pub(crate) items: Vec, pub(crate) transcript_cursor: GuardianTranscriptCursor, + pub(crate) reviewed_action_truncated: bool, } /// Points to the end of the transcript that the guardian has already reviewed. @@ -179,11 +180,12 @@ pub(crate) async fn build_guardian_prompt_items( .to_string(), ); push_text("Planned action JSON:\n".to_string()); - push_text(format!("{planned_action_json}\n")); + push_text(format!("{}\n", planned_action_json.text)); push_text(">>> APPROVAL REQUEST END\n".to_string()); Ok(GuardianPromptItems { items, transcript_cursor, + reviewed_action_truncated: planned_action_json.truncated, }) } @@ -243,7 +245,7 @@ fn render_guardian_transcript_entries_with_offset( } else { GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS }; - let text = guardian_truncate_text(&entry.text, token_cap); + let (text, _) = guardian_truncate_text(&entry.text, token_cap); let rendered = format!( "[{}] {}: {}", index + entry_number_offset + 1, @@ -423,20 +425,20 @@ pub(crate) fn collect_guardian_transcript_entries( entries } -pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String { +pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> (String, bool) { if content.is_empty() { - return String::new(); + return (String::new(), false); } let max_bytes = approx_bytes_for_tokens(token_cap); if content.len() <= max_bytes { - return content.to_string(); + return (content.to_string(), false); } let omitted_tokens = approx_tokens_from_byte_count(content.len().saturating_sub(max_bytes)); let marker = format!("<{TRUNCATION_TAG} omitted_approx_tokens=\"{omitted_tokens}\" />"); if max_bytes <= marker.len() { - return marker; + return (marker, true); } let available_bytes = max_bytes.saturating_sub(marker.len()); @@ -444,7 +446,7 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> String let suffix_budget = available_bytes.saturating_sub(prefix_budget); let (prefix, suffix) = split_guardian_truncation_bounds(content, prefix_budget, suffix_budget); - format!("{prefix}{marker}{suffix}") + (format!("{prefix}{marker}{suffix}"), true) } fn split_guardian_truncation_bounds( diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index e68024dbf0..7e2fa59b44 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -672,6 +672,7 @@ async fn run_review_on_session( ); } }; + let reviewed_action_truncated = prompt_items.reviewed_action_truncated; let transcript_cursor = prompt_items.transcript_cursor; let token_usage_at_review_start = review_session .codex @@ -711,6 +712,7 @@ async fn run_review_on_session( } Err(outcome) => return (outcome, false, analytics_result), } + analytics_result.reviewed_action_truncated = reviewed_action_truncated; let outcome = wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 9ce265e91d..587d73af7f 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -574,11 +574,12 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() { let content = "prefix ".repeat(200) + &" suffix".repeat(200); - let truncated = guardian_truncate_text(&content, /*token_cap*/ 20); + let (truncated, was_truncated) = guardian_truncate_text(&content, /*token_cap*/ 20); assert!(truncated.starts_with("prefix")); assert!(truncated.contains(" serde_json:: let rendered = format_guardian_action_pretty(&action)?; - assert!(rendered.contains("\"tool\": \"apply_patch\"")); - assert!(rendered.contains(" anyhow: ) .await; - let (GuardianReviewOutcome::Completed(first_assessment), _) = first_outcome else { + let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else { panic!("expected first guardian assessment"); }; - let (GuardianReviewOutcome::Completed(second_assessment), _) = second_outcome else { + let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome + else { panic!("expected second guardian assessment"); }; - let (GuardianReviewOutcome::Completed(third_assessment), _) = third_outcome else { + let (GuardianReviewOutcome::Completed(third_assessment), third_metadata) = third_outcome else { panic!("expected third guardian assessment"); }; assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow); assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow); + assert!(matches!( + first_metadata.guardian_session_kind, + Some(codex_analytics::GuardianReviewSessionKind::TrunkNew) + )); + assert!(matches!( + second_metadata.guardian_session_kind, + Some(codex_analytics::GuardianReviewSessionKind::TrunkReused) + )); + assert!(matches!( + third_metadata.guardian_session_kind, + Some(codex_analytics::GuardianReviewSessionKind::TrunkReused) + )); + ThreadId::from_string( + first_metadata + .guardian_thread_id + .as_deref() + .expect("first guardian thread id"), + ) + .expect("first guardian thread id should be a valid UUID"); + ThreadId::from_string( + second_metadata + .guardian_thread_id + .as_deref() + .expect("second guardian thread id"), + ) + .expect("second guardian thread id should be a valid UUID"); + ThreadId::from_string( + third_metadata + .guardian_thread_id + .as_deref() + .expect("third guardian thread id"), + ) + .expect("third guardian thread id should be a valid UUID"); + assert_eq!(first_metadata.had_prior_review_context, Some(false)); + assert_eq!(second_metadata.had_prior_review_context, Some(true)); + assert_eq!(third_metadata.had_prior_review_context, Some(true)); + assert_eq!( + first_metadata.guardian_thread_id, + second_metadata.guardian_thread_id + ); + assert_eq!( + second_metadata.guardian_thread_id, + third_metadata.guardian_thread_id + ); let requests = request_log.requests(); assert_eq!(requests.len(), 3); From 213b17b7a390f15632e89be889e54fc4ec71aa47 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 22 Apr 2026 01:52:48 -0700 Subject: [PATCH 03/11] [codex-analytics] guardian review TTFT plumbing and emission (#17696) ## Why Guardian analytics includes time-to-first-token, but the Guardian reviewer runs as a normal Codex session and `TurnCompleteEvent` did not expose TTFT. The timing needs to flow through the standard turn-completion protocol so Guardian review analytics can consume the same value as the rest of the session machinery. ## What changed Adds optional `time_to_first_token_ms` to `TurnCompleteEvent` and populates it from `TurnTiming`. The value is carried through app-server thread history, rollout reconstruction, TUI/app-server adapters, and Guardian review session handling. Guardian review analytics now captures TTFT from the reviewer turn-complete event when available. Existing tests and fixtures are updated to set the new optional field to `None` where TTFT is not relevant. ## Verification - `cargo clippy -p codex-tui --tests -- -D warnings` - `cargo clippy -p codex-core --lib --tests -- -D warnings` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/17696). * __->__ #17696 * #17695 * #17693 * #18278 * #18953 --- .../src/protocol/thread_history.rs | 16 +++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ codex-rs/core/src/guardian/review_session.rs | 13 ++++++++++-- codex-rs/core/src/guardian/tests.rs | 4 ++++ .../session/rollout_reconstruction_tests.rs | 20 +++++++++++++++++++ codex-rs/core/src/session/tests.rs | 10 ++++++++++ codex-rs/core/src/tasks/mod.rs | 5 +++++ .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/src/turn_timing.rs | 18 +++++++++++++---- codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 ++++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 6 ++++++ .../tui/src/chatwidget/tests/plan_mode.rs | 4 ++++ .../tui/src/chatwidget/tests/review_mode.rs | 2 ++ .../src/chatwidget/tests/slash_commands.rs | 5 +++++ .../src/chatwidget/tests/status_and_layout.rs | 3 +++ 18 files changed, 114 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index f7973a45de..c6090dbe11 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1357,6 +1357,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -1431,6 +1432,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -1754,6 +1756,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2267,6 +2270,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2304,6 +2308,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2356,6 +2361,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2393,6 +2399,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2567,6 +2574,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2585,6 +2593,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::AgentMessage(AgentMessageEvent { message: "still in b".into(), @@ -2596,6 +2605,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -2630,6 +2640,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), @@ -2686,6 +2697,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -2931,6 +2943,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), EventMsg::Error(ErrorEvent { message: "request-level failure".into(), @@ -2990,6 +3003,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ]; @@ -3041,6 +3055,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; @@ -3089,6 +3104,7 @@ mod tests { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 56cd688d2a..12129f7ee5 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -3107,6 +3107,7 @@ mod tests { last_agent_message: None, completed_at: Some(TEST_TURN_COMPLETED_AT), duration_ms: Some(TEST_TURN_DURATION_MS), + time_to_first_token_ms: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 3aa7d6044c..e83ee6d4b5 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -278,6 +278,7 @@ async fn on_event_updates_status_from_task_complete() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })); let expected = AgentStatus::Completed(Some("done".to_string())); assert_eq!(status, Some(expected)); @@ -1221,6 +1222,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1307,6 +1309,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7e2fa59b44..50123ef503 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -714,8 +714,13 @@ async fn run_review_on_session( } analytics_result.reviewed_action_truncated = reviewed_action_truncated; - let outcome = - wait_for_guardian_review(review_session, deadline, params.external_cancel.as_ref()).await; + let outcome = wait_for_guardian_review( + review_session, + deadline, + params.external_cancel.as_ref(), + &mut analytics_result, + ) + .await; if matches!(outcome.0, GuardianReviewSessionOutcome::Completed(_)) { if outcome.2 && let Some(total_token_usage) = review_session.codex.session.total_token_usage().await @@ -757,6 +762,7 @@ async fn wait_for_guardian_review( review_session: &GuardianReviewSession, deadline: tokio::time::Instant, external_cancel: Option<&CancellationToken>, + analytics_result: &mut GuardianReviewAnalyticsResult, ) -> (GuardianReviewSessionOutcome, bool, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); @@ -782,6 +788,9 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + analytics_result.time_to_first_token_ms = turn_complete + .time_to_first_token_ms + .and_then(|ms| u64::try_from(ms).ok()); if turn_complete.last_agent_message.is_none() && let Some(error_message) = last_error_message { diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 587d73af7f..02ff93dd06 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1074,6 +1074,10 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() request_reasoning_effort ); assert_eq!(metadata.had_prior_review_context, Some(false)); + assert!( + metadata.time_to_first_token_ms.is_some(), + "guardian review metadata should capture TTFT when the nested turn completes" + ); let mut settings = Settings::clone_current(); settings.set_snapshot_path("snapshots"); diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 3dca7f0ce6..a3cb9a7353 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -151,6 +151,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -218,6 +219,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -245,6 +247,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -314,6 +317,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -403,6 +407,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -429,6 +434,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -446,6 +452,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -518,6 +525,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -537,6 +545,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -604,6 +613,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -653,6 +663,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), // Standalone task turn (no UserMessage) should not consume rollback skips. @@ -670,6 +681,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -723,6 +735,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -879,6 +892,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -949,6 +963,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1052,6 +1067,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1160,6 +1176,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1193,6 +1210,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1276,6 +1294,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1427,6 +1446,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), RolloutItem::EventMsg(EventMsg::TurnStarted( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index e3c452b586..6915d8dae4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1644,6 +1644,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }, )), ]; @@ -1829,6 +1830,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1854,6 +1856,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -1931,6 +1934,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1949,6 +1953,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -1976,6 +1981,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -2022,6 +2028,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2045,6 +2052,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2068,6 +2076,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ]) .await; @@ -5644,6 +5653,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, + time_to_first_token_ms: None, .. }) if turn_id == tc.sub_id )); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 9c19087204..d9dfeb7db4 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -556,11 +556,16 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + let time_to_first_token_ms = turn_context + .turn_timing_state + .time_to_first_token_ms() + .await; let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, completed_at, duration_ms, + time_to_first_token_ms, }); self.send_event(turn_context.as_ref(), event).await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index a51913a6b4..b886664e35 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1155,6 +1155,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1633,6 +1634,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("first done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; @@ -1661,6 +1663,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() last_agent_message: Some("second done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), ) .await; diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 8236ae53b7..76e52d6c36 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -74,6 +74,13 @@ impl TurnTimingState { (completed_at, duration_ms) } + pub(crate) async fn time_to_first_token_ms(&self) -> Option { + let state = self.state.lock().await; + state + .time_to_first_token() + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -102,14 +109,17 @@ fn now_unix_timestamp_secs() -> i64 { } impl TurnTimingStateInner { + fn time_to_first_token(&self) -> Option { + Some(self.first_token_at?.duration_since(self.started_at?)) + } + fn record_turn_ttft(&mut self) -> Option { if self.first_token_at.is_some() { return None; } - let started_at = self.started_at?; - let first_token_at = Instant::now(); - self.first_token_at = Some(first_token_at); - Some(first_token_at.duration_since(started_at)) + self.started_at?; + self.first_token_at = Some(Instant::now()); + self.time_to_first_token() } fn record_turn_ttfm(&mut self) -> Option { diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 8ea2be3147..0368d9baf6 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -70,6 +70,7 @@ fn resume_history( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })), ], rollout_path: rollout_path.to_path_buf(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bee437ef85..34b9abdd83 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2112,6 +2112,10 @@ pub struct TurnCompleteEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] pub duration_ms: Option, + /// Duration between turn start and the first model token in milliseconds, if known. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(type = "number | null", optional)] + pub time_to_first_token_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index c700e5e14f..db5aa1314d 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -785,6 +785,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }), TurnStatus::Interrupted => events.push(Event { @@ -816,6 +817,7 @@ fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_fai last_agent_message: None, completed_at: turn.completed_at, duration_ms: turn.duration_ms, + time_to_first_token_ms: None, }), }); } diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 04a5fa44ba..cb6bea9cc6 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -654,6 +654,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { last_agent_message: Some("Final response.".into()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -699,6 +700,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -766,6 +768,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -846,6 +849,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1072,6 +1076,7 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() { last_agent_message: Some("done".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1411,6 +1416,7 @@ async fn turn_complete_keeps_unified_exec_processes() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 97681b6158..1f91a8e1e7 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -686,6 +686,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let popup = render_bottom_popup(&chat, /*width*/ 80); @@ -712,6 +713,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, })]); let replay_popup = render_bottom_popup(&chat, /*width*/ 80); assert!( @@ -726,6 +728,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -749,6 +752,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com last_agent_message: Some("Plan details".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2de9c7eb98..86bd4a26bf 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -314,6 +314,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -335,6 +336,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index d255f3e00f..457a0859e3 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -807,6 +807,7 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() { last_agent_message: Some("Final reply **markdown**".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -839,6 +840,7 @@ async fn slash_copy_state_tracks_plan_item_completion() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -925,6 +927,7 @@ async fn slash_copy_state_is_preserved_during_running_task() { last_agent_message: Some("Previous completed reply".to_string()), completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); chat.on_task_started(); @@ -955,6 +958,7 @@ async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omit last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); @@ -992,6 +996,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text( last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); let _ = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 39b72906b9..bda0729274 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1315,6 +1315,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -1665,6 +1666,7 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); @@ -2610,6 +2612,7 @@ printf 'fenced within fenced\n' last_agent_message: None, completed_at: None, duration_ms: None, + time_to_first_token_ms: None, }), }); for lines in drain_insert_history(&mut rx) { From b04ffeee4c806834bc9173455729cf47f874e836 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 22 Apr 2026 10:06:53 +0100 Subject: [PATCH 04/11] nit: expose lib (#18962) As a follow-up --- codex-rs/git-utils/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index d9d61d8650..5973b9cc41 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -1,4 +1,5 @@ mod apply; +mod baseline; mod branch; mod errors; mod ghost_commits; @@ -12,6 +13,11 @@ pub use apply::apply_git_patch; pub use apply::extract_paths_from_patch; pub use apply::parse_git_apply_output; pub use apply::stage_paths; +pub use baseline::GitBaselineChange; +pub use baseline::GitBaselineChangeStatus; +pub use baseline::GitBaselineDiff; +pub use baseline::diff_since_latest_init; +pub use baseline::reset_git_repository; pub use branch::merge_base_with_head; pub use codex_protocol::models::GhostCommit; pub use codex_protocol::protocol::GitSha; From 4f8c58f73744c6b1cebacb520cde695698745cb8 Mon Sep 17 00:00:00 2001 From: acrognale-oai Date: Wed, 22 Apr 2026 06:10:09 -0400 Subject: [PATCH 05/11] Support multiple cwd filters for thread list (#18502) ## Summary - Teach app-server `thread/list` to accept either a single `cwd` or an array of cwd filters, returning threads whose recorded session cwd matches any requested path - Add `useStateDbOnly` as an explicit opt-in fast path for callers that want to answer `thread/list` from SQLite without scanning JSONL rollout files - Preserve backwards compatibility: by default, `thread/list` still scans JSONL rollouts and repairs SQLite state - Wire the new cwd array and SQLite-only options through app-server, local/remote thread-store, rollout listing, generated TypeScript/schema fixtures, proto output, and docs ## Test Plan - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-rollout` - `cargo test -p codex-thread-store` - `cargo test -p codex-app-server thread_list` - `just fmt` - `just fix -p codex-app-server-protocol -p codex-rollout -p codex-thread-store -p codex-app-server` - `cargo build -p codex-cli --bin codex` --- .../schema/json/ClientRequest.json | 31 +- .../codex_app_server_protocol.schemas.json | 31 +- .../codex_app_server_protocol.v2.schemas.json | 31 +- .../schema/json/v2/ThreadListParams.json | 31 +- .../schema/typescript/v2/ThreadListParams.ts | 12 +- .../app-server-protocol/src/protocol/v2.rs | 60 +++- codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/README.md | 4 +- .../app-server/src/codex_message_processor.rs | 103 ++++--- .../app-server/tests/suite/v2/thread_fork.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 181 +++++++++++- .../app-server/tests/suite/v2/thread_read.rs | 1 + codex-rs/core/src/personality_migration.rs | 2 + codex-rs/core/src/realtime_context.rs | 2 + codex-rs/debug-client/src/client.rs | 1 + codex-rs/exec/src/lib.rs | 2 + codex-rs/rollout/src/list.rs | 68 ++++- codex-rs/rollout/src/recorder.rs | 256 ++++++++++++++--- codex-rs/rollout/src/recorder_tests.rs | 272 ++++++++++++++++++ codex-rs/rollout/src/state_db.rs | 8 + codex-rs/rollout/src/tests.rs | 18 ++ .../0027_threads_cwd_sort_indexes.sql | 2 + codex-rs/state/src/runtime/memories.rs | 1 + codex-rs/state/src/runtime/threads.rs | 91 ++++++ .../thread-store/src/local/archive_thread.rs | 2 + .../thread-store/src/local/list_threads.rs | 44 ++- .../thread-store/src/remote/list_threads.rs | 16 ++ .../remote/proto/codex.thread_store.v1.proto | 6 + .../src/remote/proto/codex.thread_store.v1.rs | 9 + codex-rs/thread-store/src/types.rs | 5 + codex-rs/tui/src/lib.rs | 15 +- codex-rs/tui/src/resume_picker.rs | 9 +- 32 files changed, 1183 insertions(+), 133 deletions(-) create mode 100644 codex-rs/state/migrations/0027_threads_cwd_sort_indexes.sql diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3ddced86c9..66718b105a 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3082,6 +3082,19 @@ ], "type": "object" }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "properties": { "archived": { @@ -3099,11 +3112,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -3162,6 +3179,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 231df093f3..33b0cde98e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -14868,6 +14868,19 @@ } ] }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14886,11 +14899,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -14949,6 +14966,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 842043ff30..ca0f17a265 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12762,6 +12762,19 @@ } ] }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12780,11 +12793,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -12843,6 +12860,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 62a7dc9c5b..789d9b61fc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -8,6 +8,19 @@ ], "type": "string" }, + "ThreadListCwdFilter": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, "ThreadSortKey": { "enum": [ "created_at", @@ -47,11 +60,15 @@ ] }, "cwd": { - "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ThreadListCwdFilter" + }, + { + "type": "null" + } + ], + "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", @@ -110,6 +127,10 @@ "array", "null" ] + }, + "useStateDbOnly": { + "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "type": "boolean" } }, "title": "ThreadListParams", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index a2add17685..ce5b6a79ba 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -38,10 +38,16 @@ sourceKinds?: Array | null, */ archived?: boolean | null, /** - * Optional cwd filter; when set, only threads whose session cwd exactly - * matches this path are returned. + * Optional cwd filter or filters; when set, only threads whose session cwd + * exactly matches one of these paths are returned. */ -cwd?: string | null, +cwd?: string | Array | null, +/** + * If true, return from the state DB without scanning JSONL rollouts to + * repair thread metadata. Omitted or false preserves scan-and-repair + * behavior. + */ +useStateDbOnly?: boolean, /** * Optional substring filter for the extracted thread title. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1720b5005d..eabb32ecf9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3703,15 +3703,27 @@ pub struct ThreadListParams { /// If false or null, only non-archived threads are returned. #[ts(optional = nullable)] pub archived: Option, - /// Optional cwd filter; when set, only threads whose session cwd exactly - /// matches this path are returned. - #[ts(optional = nullable)] - pub cwd: Option, + /// Optional cwd filter or filters; when set, only threads whose session cwd + /// exactly matches one of these paths are returned. + #[ts(optional = nullable, type = "string | Array | null")] + pub cwd: Option, + /// If true, return from the state DB without scanning JSONL rollouts to + /// repair thread metadata. Omitted or false preserves scan-and-repair + /// behavior. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub use_state_db_only: bool, /// Optional substring filter for the extracted thread title. #[ts(optional = nullable)] pub search_term: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ThreadListCwdFilter { + One(String), + Many(Vec), +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -7294,6 +7306,46 @@ mod tests { absolute_path("readable") } + #[test] + fn thread_list_params_accepts_single_cwd() { + let params = serde_json::from_value::(json!({ + "cwd": "/workspace", + })) + .expect("single cwd should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One("/workspace".to_string())) + ); + assert!(!params.use_state_db_only); + } + + #[test] + fn thread_list_params_accepts_multiple_cwds() { + let params = serde_json::from_value::(json!({ + "cwd": ["/workspace", "/other-workspace"], + })) + .expect("cwd array should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::Many(vec![ + "/workspace".to_string(), + "/other-workspace".to_string(), + ])) + ); + } + + #[test] + fn thread_list_params_accepts_state_db_only_flag() { + let params = serde_json::from_value::(json!({ + "useStateDbOnly": true, + })) + .expect("state db only flag should deserialize"); + + assert!(params.use_state_db_only); + } + #[test] fn collab_agent_state_maps_interrupted_status() { assert_eq!( diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 6f6e0dfc83..cf28cb151c 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1129,6 +1129,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, })?; println!("< thread/list response: {response:?}"); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 91ada0ab82..2452cba36b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -290,7 +290,8 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. - `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). -- `cwd` — restrict results to threads whose session cwd exactly matches this path. Relative paths are resolved against the app-server process cwd before matching. +- `cwd` — restrict results to threads whose session cwd exactly matches this path, or one of these paths when an array is provided. Relative paths are resolved against the app-server process cwd before matching. +- `useStateDbOnly` — when `true`, return from the state DB without scanning JSONL rollouts to repair metadata. Omit or pass `false` to preserve the default scan-and-repair behavior. - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). - Responses include `nextCursor` to continue in the same direction and `backwardsCursor` to pass as `cursor` when reversing `sortDirection`. - Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. @@ -301,6 +302,7 @@ Example: { "method": "thread/list", "id": 20, "params": { "cursor": null, "limit": 25, + "cwd": ["/Users/me/project", "/Users/me/project-worktree"], "sortKey": "created_at" } } { "id": 20, "result": { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d6efa1050b..c8b9200e3e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -150,6 +150,7 @@ use codex_app_server_protocol::ThreadIncrementElicitationResponse; use codex_app_server_protocol::ThreadInjectItemsParams; use codex_app_server_protocol::ThreadInjectItemsResponse; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; @@ -402,8 +403,9 @@ struct ThreadListFilters { model_providers: Option>, source_kinds: Option>, archived: bool, - cwd: Option, + cwd_filters: Option>, search_term: Option, + use_state_db_only: bool, } // Duration before a browser ChatGPT login attempt is abandoned. @@ -3722,10 +3724,11 @@ impl CodexMessageProcessor { source_kinds, archived, cwd, + use_state_db_only, search_term, } = params; - let cwd = match normalize_thread_list_cwd_filter(cwd) { - Ok(cwd) => cwd, + let cwd_filters = match normalize_thread_list_cwd_filters(cwd) { + Ok(cwd_filters) => cwd_filters, Err(error) => { self.outgoing.send_error(request_id, error).await; return; @@ -3751,8 +3754,9 @@ impl CodexMessageProcessor { model_providers, source_kinds, archived: archived.unwrap_or(false), - cwd, + cwd_filters, search_term, + use_state_db_only, }, ) .await; @@ -5217,8 +5221,9 @@ impl CodexMessageProcessor { model_providers, source_kinds, archived, - cwd, + cwd_filters, search_term, + use_state_db_only, } = filters; let mut cursor_obj = cursor; let mut last_cursor = cursor_obj.clone(); @@ -5255,28 +5260,27 @@ impl CodexMessageProcessor { sort_direction: store_sort_direction, allowed_sources: allowed_sources.to_vec(), model_providers: model_provider_filter.clone(), + cwd_filters: cwd_filters.clone(), archived, search_term: search_term.clone(), + use_state_db_only, }) .await .map_err(thread_store_list_error)?; - let mut candidate_summaries = Vec::with_capacity(page.items.len()); + let mut filtered = Vec::with_capacity(page.items.len()); for it in page.items { let Some(summary) = summary_from_stored_thread(it, fallback_provider.as_str()) else { continue; }; - candidate_summaries.push(summary); - } - - let mut filtered = Vec::with_capacity(candidate_summaries.len()); - for summary in candidate_summaries { if source_kind_filter .as_ref() .is_none_or(|filter| source_kind_matches(&summary.source, filter)) - && cwd.as_ref().is_none_or(|expected_cwd| { - path_utils::paths_match_after_normalization(&summary.cwd, expected_cwd) + && cwd_filters.as_ref().is_none_or(|expected_cwds| { + expected_cwds.iter().any(|expected_cwd| { + path_utils::paths_match_after_normalization(&summary.cwd, expected_cwd) + }) }) { filtered.push(summary); @@ -5288,25 +5292,22 @@ impl CodexMessageProcessor { items.extend(filtered); remaining = requested_page_size.saturating_sub(items.len()); - let next_cursor_value = page.next_cursor.clone(); - next_cursor = next_cursor_value.clone(); + next_cursor = page.next_cursor; if remaining == 0 { break; } - match next_cursor_value { - Some(cursor_val) if remaining > 0 => { - // Break if our pagination would reuse the same cursor again; this avoids - // an infinite loop when filtering drops everything on the page. - if last_cursor.as_ref() == Some(&cursor_val) { - next_cursor = None; - break; - } - last_cursor = Some(cursor_val.clone()); - cursor_obj = Some(cursor_val); - } - _ => break, + let Some(cursor_val) = next_cursor.clone() else { + break; + }; + // Break if our pagination would reuse the same cursor again; this avoids + // an infinite loop when filtering drops everything on the page. + if last_cursor.as_ref() == Some(&cursor_val) { + next_cursor = None; + break; } + last_cursor = Some(cursor_val.clone()); + cursor_obj = Some(cursor_val); } Ok((items, next_cursor)) @@ -8273,25 +8274,36 @@ impl CodexMessageProcessor { } } -fn normalize_thread_list_cwd_filter( - cwd: Option, -) -> Result, JSONRPCErrorError> { +fn normalize_thread_list_cwd_filters( + cwd: Option, +) -> Result>, JSONRPCErrorError> { let Some(cwd) = cwd else { return Ok(None); }; - AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) - .map(AbsolutePathBuf::into_path_buf) - .map(Some) - .map_err(|err| JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!("invalid thread/list cwd filter `{cwd}`: {err}"), - data: None, - }) + + let cwds = match cwd { + ThreadListCwdFilter::One(cwd) => vec![cwd], + ThreadListCwdFilter::Many(cwds) => cwds, + }; + let mut normalized_cwds = Vec::with_capacity(cwds.len()); + for cwd in cwds { + let cwd = AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!("invalid thread/list cwd filter `{cwd}`: {err}"), + data: None, + })?; + normalized_cwds.push(cwd); + } + + Ok(Some(normalized_cwds)) } #[cfg(test)] mod thread_list_cwd_filter_tests { - use super::normalize_thread_list_cwd_filter; + use super::normalize_thread_list_cwd_filters; + use codex_app_server_protocol::ThreadListCwdFilter; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -8305,8 +8317,9 @@ mod thread_list_cwd_filter_tests { }; assert_eq!( - normalize_thread_list_cwd_filter(Some(cwd.clone())).expect("cwd filter should parse"), - Some(PathBuf::from(cwd)) + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::One(cwd.clone()))) + .expect("cwd filter should parse"), + Some(vec![PathBuf::from(cwd)]) ); } @@ -8316,9 +8329,11 @@ mod thread_list_cwd_filter_tests { let expected = AbsolutePathBuf::relative_to_current_dir("repo-b")?.to_path_buf(); assert_eq!( - normalize_thread_list_cwd_filter(Some(String::from("repo-b"))) - .expect("cwd filter should parse"), - Some(expected) + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::Many(vec![String::from( + "repo-b" + ),]))) + .expect("cwd filter should parse"), + Some(vec![expected]) ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 89980c7c62..0425ce3e3c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -548,6 +548,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 247bbb4126..615692d70d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; @@ -90,6 +91,7 @@ async fn list_threads_with_sort( source_kinds, archived, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; @@ -468,15 +470,23 @@ async fn thread_list_respects_provider_filter() -> Result<()> { } #[tokio::test] -async fn thread_list_respects_cwd_filter() -> Result<()> { +async fn thread_list_respects_cwd_filters() -> Result<()> { let codex_home = TempDir::new()?; create_minimal_config(codex_home.path())?; - let filtered_id = create_fake_rollout( + let first_filtered_id = create_fake_rollout( codex_home.path(), "2025-01-02T10-00-00", "2025-01-02T10:00:00Z", - "filtered", + "first filtered", + Some("mock_provider"), + /*git_info*/ None, + )?; + let second_filtered_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "second filtered", Some("mock_provider"), /*git_info*/ None, )?; @@ -489,11 +499,22 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { /*git_info*/ None, )?; - let target_cwd = codex_home.path().join("target-cwd"); - fs::create_dir_all(&target_cwd)?; + let first_target_cwd = codex_home.path().join("first-target-cwd"); + let second_target_cwd = codex_home.path().join("second-target-cwd"); + fs::create_dir_all(&first_target_cwd)?; + fs::create_dir_all(&second_target_cwd)?; set_rollout_cwd( - rollout_path(codex_home.path(), "2025-01-02T10-00-00", &filtered_id).as_path(), - &target_cwd, + rollout_path(codex_home.path(), "2025-01-02T10-00-00", &first_filtered_id).as_path(), + &first_target_cwd, + )?; + set_rollout_cwd( + rollout_path( + codex_home.path(), + "2025-01-02T12-00-00", + &second_filtered_id, + ) + .as_path(), + &second_target_cwd, )?; let mut mcp = init_mcp(codex_home.path()).await?; @@ -506,7 +527,11 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, - cwd: Some(target_cwd.to_string_lossy().into_owned()), + cwd: Some(ThreadListCwdFilter::Many(vec![ + first_target_cwd.to_string_lossy().into_owned(), + second_target_cwd.to_string_lossy().into_owned(), + ])), + use_state_db_only: false, search_term: None, }) .await?; @@ -520,10 +545,14 @@ async fn thread_list_respects_cwd_filter() -> Result<()> { } = to_response::(resp)?; assert_eq!(next_cursor, None); - assert_eq!(data.len(), 1); - assert_eq!(data[0].id, filtered_id); - assert_ne!(data[0].id, unfiltered_id); - assert_eq!(data[0].cwd.as_path(), target_cwd.as_path()); + let filtered_ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect(); + assert_eq!( + filtered_ids, + vec![second_filtered_id.as_str(), first_filtered_id.as_str()] + ); + assert!(!filtered_ids.contains(&unfiltered_id.as_str())); + assert_eq!(data[0].cwd.as_path(), second_target_cwd.as_path()); + assert_eq!(data[1].cwd.as_path(), first_target_cwd.as_path()); Ok(()) } @@ -592,6 +621,7 @@ sqlite = true codex_core::SortDirection::Desc, &[], /*model_providers*/ None, + /*cwd_filters*/ None, "mock_provider", /*search_term*/ None, ) @@ -609,6 +639,7 @@ sqlite = true source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: Some("needle".to_string()), }) .await?; @@ -628,6 +659,129 @@ sqlite = true Ok(()) } +#[tokio::test] +async fn thread_list_state_db_only_returns_sqlite_without_jsonl_repair() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +model = "mock-model" +approval_policy = "never" +suppress_unstable_features_warning = true + +[features] +sqlite = true +"#, + )?; + + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "state db only should not see this before repair", + Some("mock_provider"), + /*git_info*/ None, + )?; + let state_db = + codex_state::StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()) + .await?; + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await?; + let mut mcp = init_mcp(codex_home.path()).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let repaired_response = to_response::(resp)?; + let ids: Vec<_> = repaired_response + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![thread_id.as_str()]); + + let thread_uuid = ThreadId::from_string(&thread_id)?; + let stale_cwd = codex_home.path().join("stale-cwd"); + let mut metadata = state_db + .get_thread(thread_uuid) + .await? + .expect("thread should be repaired into sqlite"); + metadata.cwd = stale_cwd.clone(); + state_db.upsert_thread(&metadata).await?; + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(ThreadListCwdFilter::One( + stale_cwd.to_string_lossy().into_owned(), + )), + use_state_db_only: true, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let state_db_only_response = to_response::(resp)?; + let ids: Vec<_> = state_db_only_response + .data + .iter() + .map(|thread| thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![thread_id.as_str()]); + + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + model_providers: Some(vec!["mock_provider".to_string()]), + source_kinds: None, + archived: None, + cwd: Some(ThreadListCwdFilter::One( + stale_cwd.to_string_lossy().into_owned(), + )), + use_state_db_only: false, + search_term: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let scanned_response = to_response::(resp)?; + assert_eq!(scanned_response.data.len(), 0); + + Ok(()) +} + #[tokio::test] async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> { let codex_home = TempDir::new()?; @@ -1307,6 +1461,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; @@ -1348,6 +1503,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; @@ -1585,6 +1741,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 7cee2f0990..8e0e253ac0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -548,6 +548,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }) .await?; diff --git a/codex-rs/core/src/personality_migration.rs b/codex-rs/core/src/personality_migration.rs index ae24499e02..19bf00284f 100644 --- a/codex-rs/core/src/personality_migration.rs +++ b/codex-rs/core/src/personality_migration.rs @@ -82,8 +82,10 @@ async fn has_threads(store: &LocalThreadStore, archived: bool) -> io::Result Vec { sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, + cwd_filters: None, archived: false, search_term: None, + use_state_db_only: false, }) .await { diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index c8cf0dd72d..2edabfac00 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -183,6 +183,7 @@ impl AppServerClient { source_kinds: None, archived: None, cwd: None, + use_state_db_only: false, search_term: None, }, }; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 11bf1b7a60..ca3b650778 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1256,6 +1256,7 @@ async fn resolve_resume_thread_id( source_kinds: Some(all_thread_source_kinds()), archived: Some(false), cwd: None, + use_state_db_only: false, search_term: None, }, }, @@ -1319,6 +1320,7 @@ async fn resolve_resume_thread_id( source_kinds: Some(all_thread_source_kinds()), archived: Some(false), cwd: None, + use_state_db_only: false, search_term: Some(session_id.to_string()), }, }, diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index bf09d15e86..bdb7198835 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -1,6 +1,7 @@ #![allow(warnings, clippy::all)] use async_trait::async_trait; +use codex_utils_path as path_utils; use std::cmp::Reverse; use std::ffi::OsStr; use std::io; @@ -126,6 +127,7 @@ pub enum ThreadListLayout { pub struct ThreadListConfig<'a> { pub allowed_sources: &'a [SessionSource], pub model_providers: Option<&'a [String]>, + pub cwd_filters: Option<&'a [PathBuf]>, pub default_provider: &'a str, pub layout: ThreadListLayout, } @@ -207,6 +209,7 @@ struct FilesByCreatedAtVisitor<'a> { more_matches_available: bool, allowed_sources: &'a [SessionSource], provider_matcher: Option<&'a ProviderMatcher<'a>>, + cwd_filters: Option<&'a [PathBuf]>, } #[async_trait] @@ -237,6 +240,7 @@ impl<'a> RolloutFileVisitor for FilesByCreatedAtVisitor<'a> { path, self.allowed_sources, self.provider_matcher, + self.cwd_filters, updated_at, ) .await @@ -317,6 +321,7 @@ pub async fn get_threads( sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, default_provider: &str, ) -> io::Result { let root = codex_home.join(SESSIONS_SUBDIR); @@ -328,6 +333,7 @@ pub async fn get_threads( ThreadListConfig { allowed_sources, model_providers, + cwd_filters, default_provider, layout: ThreadListLayout::NestedByDate, }, @@ -366,6 +372,7 @@ pub async fn get_threads_in_root( sort_key, config.allowed_sources, provider_matcher.as_ref(), + config.cwd_filters, ) .await? } @@ -377,6 +384,7 @@ pub async fn get_threads_in_root( sort_key, config.allowed_sources, provider_matcher.as_ref(), + config.cwd_filters, ) .await? } @@ -395,6 +403,7 @@ async fn traverse_directories_for_paths( sort_key: ThreadSortKey, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, + cwd_filters: Option<&[PathBuf]>, ) -> io::Result { match sort_key { ThreadSortKey::CreatedAt => { @@ -404,6 +413,7 @@ async fn traverse_directories_for_paths( anchor, allowed_sources, provider_matcher, + cwd_filters, ) .await } @@ -414,6 +424,7 @@ async fn traverse_directories_for_paths( anchor, allowed_sources, provider_matcher, + cwd_filters, ) .await } @@ -427,15 +438,30 @@ async fn traverse_flat_paths( sort_key: ThreadSortKey, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, + cwd_filters: Option<&[PathBuf]>, ) -> io::Result { match sort_key { ThreadSortKey::CreatedAt => { - traverse_flat_paths_created(root, page_size, anchor, allowed_sources, provider_matcher) - .await + traverse_flat_paths_created( + root, + page_size, + anchor, + allowed_sources, + provider_matcher, + cwd_filters, + ) + .await } ThreadSortKey::UpdatedAt => { - traverse_flat_paths_updated(root, page_size, anchor, allowed_sources, provider_matcher) - .await + traverse_flat_paths_updated( + root, + page_size, + anchor, + allowed_sources, + provider_matcher, + cwd_filters, + ) + .await } } } @@ -452,6 +478,7 @@ async fn traverse_directories_for_paths_created( anchor: Option, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, + cwd_filters: Option<&[PathBuf]>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; @@ -463,6 +490,7 @@ async fn traverse_directories_for_paths_created( more_matches_available, allowed_sources, provider_matcher, + cwd_filters, }; walk_rollout_files(&root, &mut scanned_files, &mut visitor).await?; more_matches_available = visitor.more_matches_available; @@ -499,14 +527,14 @@ async fn traverse_directories_for_paths_updated( anchor: Option, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, + cwd_filters: Option<&[PathBuf]>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; let mut anchor_state = AnchorState::new(anchor); let mut more_matches_available = false; - let candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?; - let mut candidates = candidates; + let mut candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?; candidates.sort_by_key(|candidate| { let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); (Reverse(ts), Reverse(candidate.id)) @@ -527,6 +555,7 @@ async fn traverse_directories_for_paths_updated( candidate.path, allowed_sources, provider_matcher, + cwd_filters, updated_at_fallback, ) .await @@ -559,6 +588,7 @@ async fn traverse_flat_paths_created( anchor: Option, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, + cwd_filters: Option<&[PathBuf]>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; @@ -578,8 +608,14 @@ async fn traverse_flat_paths_created( .await .unwrap_or(None) .and_then(format_rfc3339); - if let Some(item) = - build_thread_item(path, allowed_sources, provider_matcher, updated_at).await + if let Some(item) = build_thread_item( + path, + allowed_sources, + provider_matcher, + cwd_filters, + updated_at, + ) + .await { items.push(item); } @@ -609,14 +645,14 @@ async fn traverse_flat_paths_updated( anchor: Option, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, + cwd_filters: Option<&[PathBuf]>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; let mut anchor_state = AnchorState::new(anchor); let mut more_matches_available = false; - let candidates = collect_flat_files_by_updated_at(&root, &mut scanned_files).await?; - let mut candidates = candidates; + let mut candidates = collect_flat_files_by_updated_at(&root, &mut scanned_files).await?; candidates.sort_by_key(|candidate| { let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); (Reverse(ts), Reverse(candidate.id)) @@ -637,6 +673,7 @@ async fn traverse_flat_paths_updated( candidate.path, allowed_sources, provider_matcher, + cwd_filters, updated_at_fallback, ) .await @@ -698,6 +735,7 @@ async fn build_thread_item( path: PathBuf, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, + cwd_filters: Option<&[PathBuf]>, updated_at: Option, ) -> Option { // Read head and detect message events; stop once meta + user are found. @@ -717,6 +755,15 @@ async fn build_thread_item( { return None; } + if let Some(cwd_filters) = cwd_filters + && !summary.cwd.as_ref().is_some_and(|cwd| { + cwd_filters + .iter() + .any(|filter| path_utils::paths_match_after_normalization(cwd, filter)) + }) + { + return None; + } // Apply filters: must have session meta and at least one user message event if summary.saw_session_meta && summary.saw_user_event { let HeadTailSummary { @@ -768,6 +815,7 @@ pub async fn read_thread_item_from_rollout(path: PathBuf) -> Option path, &[], /*provider_matcher*/ None, + /*cwd_filters*/ None, /*updated_at*/ None, ) .await diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 871d53141b..184cfc1919 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -214,6 +214,18 @@ fn sanitize_rollout_item_for_persistence( } } +#[derive(Clone, Copy)] +enum ThreadListArchiveFilter { + Active, + Archived, +} + +#[derive(Clone, Copy)] +enum ThreadListRepairMode { + ScanAndRepair, + StateDbOnly, +} + impl RolloutRecorder { /// List threads (rollout files) under the provided Codex home directory. #[allow(clippy::too_many_arguments)] @@ -225,6 +237,7 @@ impl RolloutRecorder { sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, default_provider: &str, search_term: Option<&str>, ) -> std::io::Result { @@ -236,8 +249,40 @@ impl RolloutRecorder { sort_direction, allowed_sources, model_providers, + cwd_filters, default_provider, - /*archived*/ false, + ThreadListArchiveFilter::Active, + ThreadListRepairMode::ScanAndRepair, + search_term, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn list_threads_from_state_db( + config: &impl RolloutConfigView, + page_size: usize, + cursor: Option<&Cursor>, + sort_key: ThreadSortKey, + sort_direction: SortDirection, + allowed_sources: &[SessionSource], + model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, + default_provider: &str, + search_term: Option<&str>, + ) -> std::io::Result { + Self::list_threads_with_db_fallback( + config, + page_size, + cursor, + sort_key, + sort_direction, + allowed_sources, + model_providers, + cwd_filters, + default_provider, + ThreadListArchiveFilter::Active, + ThreadListRepairMode::StateDbOnly, search_term, ) .await @@ -253,6 +298,7 @@ impl RolloutRecorder { sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, default_provider: &str, search_term: Option<&str>, ) -> std::io::Result { @@ -264,8 +310,40 @@ impl RolloutRecorder { sort_direction, allowed_sources, model_providers, + cwd_filters, default_provider, - /*archived*/ true, + ThreadListArchiveFilter::Archived, + ThreadListRepairMode::ScanAndRepair, + search_term, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn list_archived_threads_from_state_db( + config: &impl RolloutConfigView, + page_size: usize, + cursor: Option<&Cursor>, + sort_key: ThreadSortKey, + sort_direction: SortDirection, + allowed_sources: &[SessionSource], + model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, + default_provider: &str, + search_term: Option<&str>, + ) -> std::io::Result { + Self::list_threads_with_db_fallback( + config, + page_size, + cursor, + sort_key, + sort_direction, + allowed_sources, + model_providers, + cwd_filters, + default_provider, + ThreadListArchiveFilter::Archived, + ThreadListRepairMode::StateDbOnly, search_term, ) .await @@ -280,19 +358,24 @@ impl RolloutRecorder { sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, default_provider: &str, - archived: bool, + archive_filter: ThreadListArchiveFilter, + repair_mode: ThreadListRepairMode, search_term: Option<&str>, ) -> std::io::Result { let codex_home = config.codex_home(); let state_db_ctx = state_db::get_state_db(config).await; + let archived = match archive_filter { + ThreadListArchiveFilter::Active => false, + ThreadListArchiveFilter::Archived => true, + }; + if cwd_filters.is_some_and(<[std::path::PathBuf]>::is_empty) { + return Ok(ThreadsPage::default()); + } - // Search is the SQLite-optimized path and assumes a DB marked backfill-complete is - // actually populated enough to answer the query. If unmigrated rollout files still exist - // on disk, the repair path below may or may not run and catch them depending on whether - // SQLite already has another matching search hit. - if search_term.is_some() - && let Some(db_page) = state_db::list_threads_db( + if matches!(repair_mode, ThreadListRepairMode::StateDbOnly) { + return Ok(state_db::list_threads_db( state_db_ctx.as_deref(), codex_home, page_size, @@ -301,18 +384,22 @@ impl RolloutRecorder { sort_direction, allowed_sources, model_providers, + cwd_filters, archived, search_term, ) .await - && (!db_page.items.is_empty() || cursor.is_some()) - { - return Ok(db_page.into()); + .map(Into::into) + .unwrap_or_default()); } + let listing_has_metadata_filters = !allowed_sources.is_empty() + || model_providers.is_some() + || cwd_filters.is_some() + || search_term.is_some(); // Filesystem-first listing intentionally overfetches so we can repair stale/missing - // SQLite rollout paths before the final DB-backed page is returned. - let fs_page_size = page_size.saturating_mul(2).max(page_size); + // SQLite rows before returning the scan page for filtered listings or the DB page for + // unfiltered listings. let fs_page = match sort_direction { SortDirection::Asc => { list_threads_from_files_asc( @@ -322,6 +409,7 @@ impl RolloutRecorder { sort_key, allowed_sources, model_providers, + cwd_filters, default_provider, archived, search_term, @@ -331,11 +419,12 @@ impl RolloutRecorder { SortDirection::Desc => { list_threads_from_files_desc( codex_home, - fs_page_size, + page_size.saturating_mul(2), cursor, sort_key, allowed_sources, model_providers, + cwd_filters, default_provider, archived, search_term, @@ -347,24 +436,39 @@ impl RolloutRecorder { if state_db_ctx.is_none() { // Keep legacy behavior when SQLite is unavailable: return filesystem results // at the requested page size. - return Ok(match sort_direction { - SortDirection::Asc => fs_page, - SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key), - }); + return Ok(page_from_filesystem_scan( + fs_page, + sort_direction, + page_size, + sort_key, + )); } // Warm the DB by repairing every filesystem hit before querying SQLite. for item in &fs_page.items { - state_db::read_repair_rollout_path( - state_db_ctx.as_deref(), - item.thread_id, - Some(archived), - item.path.as_path(), - ) - .await; + if listing_has_metadata_filters { + state_db::reconcile_rollout( + state_db_ctx.as_deref(), + item.path.as_path(), + default_provider, + /*builder*/ None, + &[], + Some(archived), + /*new_thread_memory_mode*/ None, + ) + .await; + } else { + state_db::read_repair_rollout_path( + state_db_ctx.as_deref(), + item.thread_id, + Some(archived), + item.path.as_path(), + ) + .await; + } } - if let Some(db_page) = state_db::list_threads_db( + let db_page = state_db::list_threads_db( state_db_ctx.as_deref(), codex_home, page_size, @@ -373,20 +477,83 @@ impl RolloutRecorder { sort_direction, allowed_sources, model_providers, + cwd_filters, archived, search_term, ) - .await - { + .await; + if let Some(db_page) = db_page { + if search_term.is_some() && (!db_page.items.is_empty() || cursor.is_some()) { + for item in &db_page.items { + state_db::reconcile_rollout( + state_db_ctx.as_deref(), + item.rollout_path.as_path(), + default_provider, + /*builder*/ None, + &[], + Some(archived), + /*new_thread_memory_mode*/ None, + ) + .await; + } + if let Some(repaired_db_page) = state_db::list_threads_db( + state_db_ctx.as_deref(), + codex_home, + page_size, + cursor, + sort_key, + sort_direction, + allowed_sources, + model_providers, + cwd_filters, + archived, + search_term, + ) + .await + { + return Ok(repaired_db_page.into()); + } + return Ok(db_page.into()); + } + if listing_has_metadata_filters { + for item in &db_page.items { + state_db::reconcile_rollout( + state_db_ctx.as_deref(), + item.rollout_path.as_path(), + default_provider, + /*builder*/ None, + &[], + Some(archived), + /*new_thread_memory_mode*/ None, + ) + .await; + } + return Ok(page_from_filesystem_scan( + fs_page, + sort_direction, + page_size, + sort_key, + )); + } return Ok(db_page.into()); } + if listing_has_metadata_filters { + return Ok(page_from_filesystem_scan( + fs_page, + sort_direction, + page_size, + sort_key, + )); + } // If SQLite listing still fails, return the filesystem page rather than failing the list. tracing::error!("Falling back on rollout system"); tracing::warn!("state db discrepancy during list_threads_with_db_fallback: falling_back"); - Ok(match sort_direction { - SortDirection::Asc => fs_page, - SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key), - }) + Ok(page_from_filesystem_scan( + fs_page, + sort_direction, + page_size, + sort_key, + )) } /// Find the newest recorded thread path, optionally filtering to a matching cwd. @@ -403,6 +570,7 @@ impl RolloutRecorder { ) -> std::io::Result> { let codex_home = config.codex_home(); let state_db_ctx = state_db::get_state_db(config).await; + let cwd_filter = filter_cwd.map(Path::to_path_buf); if state_db_ctx.is_some() { let mut db_cursor = cursor.cloned(); loop { @@ -415,6 +583,7 @@ impl RolloutRecorder { SortDirection::Desc, allowed_sources, model_providers, + cwd_filter.as_ref().map(std::slice::from_ref), /*archived*/ false, /*search_term*/ None, ) @@ -443,6 +612,7 @@ impl RolloutRecorder { sort_key, allowed_sources, model_providers, + cwd_filter.as_ref().map(std::slice::from_ref), default_provider, ) .await?; @@ -796,6 +966,18 @@ fn truncate_fs_page( page } +fn page_from_filesystem_scan( + page: ThreadsPage, + sort_direction: SortDirection, + page_size: usize, + sort_key: ThreadSortKey, +) -> ThreadsPage { + match sort_direction { + SortDirection::Asc => page, + SortDirection::Desc => truncate_fs_page(page, page_size, sort_key), + } +} + #[allow(clippy::too_many_arguments)] async fn list_threads_from_files_desc( codex_home: &Path, @@ -804,6 +986,7 @@ async fn list_threads_from_files_desc( sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, default_provider: &str, archived: bool, search_term: Option<&str>, @@ -823,6 +1006,7 @@ async fn list_threads_from_files_desc( sort_key, allowed_sources, model_providers, + cwd_filters, default_provider, archived, ) @@ -864,6 +1048,7 @@ async fn list_threads_from_files_desc( sort_key, allowed_sources, model_providers, + cwd_filters, default_provider, archived, ) @@ -878,6 +1063,7 @@ async fn list_threads_from_files_desc_unfiltered( sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, default_provider: &str, archived: bool, ) -> std::io::Result { @@ -891,6 +1077,7 @@ async fn list_threads_from_files_desc_unfiltered( ThreadListConfig { allowed_sources, model_providers, + cwd_filters, default_provider, layout: ThreadListLayout::Flat, }, @@ -904,6 +1091,7 @@ async fn list_threads_from_files_desc_unfiltered( sort_key, allowed_sources, model_providers, + cwd_filters, default_provider, ) .await @@ -918,6 +1106,7 @@ async fn list_threads_from_files_asc( sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, default_provider: &str, archived: bool, search_term: Option<&str>, @@ -935,6 +1124,7 @@ async fn list_threads_from_files_asc( sort_key, allowed_sources, model_providers, + cwd_filters, default_provider, archived, /*search_term*/ None, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index fa789d3a7e..852bf6c6df 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -375,6 +375,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re SortDirection::Desc, &[], /*model_providers*/ None, + /*cwd_filters*/ None, default_provider.as_str(), /*search_term*/ None, ) @@ -391,6 +392,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re SortDirection::Desc, &[], /*model_providers*/ None, + /*cwd_filters*/ None, default_provider.as_str(), /*search_term*/ None, ) @@ -449,6 +451,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul SortDirection::Desc, &[], /*model_providers*/ None, + /*cwd_filters*/ None, default_provider.as_str(), /*search_term*/ None, ) @@ -512,6 +515,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul SortDirection::Desc, &[], /*model_providers*/ None, + /*cwd_filters*/ None, default_provider.as_str(), /*search_term*/ None, ) @@ -527,6 +531,274 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul Ok(()) } +#[tokio::test] +async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + runtime + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); + + let uuid = Uuid::from_u128(9012); + let ts = "2025-01-03T14-00-00"; + let day_dir = home.path().join("sessions/2025/01/03"); + fs::create_dir_all(&day_dir)?; + let path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl")); + let mut file = File::create(&path)?; + let meta = serde_json::json!({ + "timestamp": ts, + "type": "session_meta", + "payload": { + "id": uuid, + "timestamp": ts, + "cwd": home.path().display().to_string(), + "originator": "test_originator", + "cli_version": "test_version", + "source": "cli", + "model_provider": "test-provider", + }, + }); + writeln!(file, "{meta}")?; + let user_event = serde_json::json!({ + "timestamp": ts, + "type": "event_msg", + "payload": { + "type": "user_message", + "message": "Hello from user", + "kind": "plain", + }, + }); + writeln!(file, "{user_event}")?; + + let cwd_filters = [home.path().to_path_buf()]; + let state_db_only_page = RolloutRecorder::list_threads_from_state_db( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ Some(cwd_filters.as_slice()), + config.model_provider_id.as_str(), + /*search_term*/ None, + ) + .await?; + assert_eq!(state_db_only_page.items.len(), 0); + + let repaired_page = RolloutRecorder::list_threads( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ Some(cwd_filters.as_slice()), + config.model_provider_id.as_str(), + /*search_term*/ None, + ) + .await?; + assert_eq!(repaired_page.items.len(), 1); + + let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ Some(cwd_filters.as_slice()), + config.model_provider_id.as_str(), + /*search_term*/ None, + ) + .await?; + assert_eq!(repaired_state_db_only_page.items.len(), 1); + Ok(()) +} + +#[tokio::test] +async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + + let uuid = Uuid::from_u128(9013); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let real_path = write_session_file(home.path(), "2025-01-03T13-00-00", uuid)?; + let stale_cwd = home.path().join("stale-cwd"); + + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + runtime + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); + let created_at = chrono::Utc + .with_ymd_and_hms(2025, 1, 3, 13, 0, 0) + .single() + .expect("valid datetime"); + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + real_path, + created_at, + SessionSource::Cli, + ); + builder.model_provider = Some(config.model_provider_id.clone()); + builder.cwd = stale_cwd.clone(); + let mut metadata = builder.build(config.model_provider_id.as_str()); + metadata.first_user_message = Some("Hello from user".to_string()); + runtime + .upsert_thread(&metadata) + .await + .expect("state db upsert should succeed"); + + let cwd_filters = [stale_cwd]; + let state_db_only_page = RolloutRecorder::list_threads_from_state_db( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ Some(cwd_filters.as_slice()), + config.model_provider_id.as_str(), + /*search_term*/ None, + ) + .await?; + assert_eq!(state_db_only_page.items.len(), 1); + + let scanned_page = RolloutRecorder::list_threads( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ Some(cwd_filters.as_slice()), + config.model_provider_id.as_str(), + /*search_term*/ None, + ) + .await?; + assert_eq!(scanned_page.items.len(), 0); + + let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ Some(cwd_filters.as_slice()), + config.model_provider_id.as_str(), + /*search_term*/ None, + ) + .await?; + assert_eq!(repaired_state_db_only_page.items.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + + let uuid = Uuid::from_u128(9014); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let real_path = write_session_file(home.path(), "2025-01-03T15-00-00", uuid)?; + + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + runtime + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); + let created_at = chrono::Utc + .with_ymd_and_hms(2025, 1, 3, 15, 0, 0) + .single() + .expect("valid datetime"); + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + real_path, + created_at, + SessionSource::Cli, + ); + builder.model_provider = Some(config.model_provider_id.clone()); + builder.cwd = home.path().to_path_buf(); + let mut metadata = builder.build(config.model_provider_id.as_str()); + metadata.title = "needle stale title".to_string(); + metadata.first_user_message = Some("stale first user".to_string()); + runtime + .upsert_thread(&metadata) + .await + .expect("state db upsert should succeed"); + + let stale_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ None, + config.model_provider_id.as_str(), + Some("needle"), + ) + .await?; + assert_eq!(stale_state_db_only_page.items.len(), 1); + + let scanned_page = RolloutRecorder::list_threads( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ None, + config.model_provider_id.as_str(), + Some("needle"), + ) + .await?; + assert_eq!(scanned_page.items.len(), 0); + + let repaired_state_db_only_page = RolloutRecorder::list_threads_from_state_db( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[], + /*model_providers*/ None, + /*cwd_filters*/ None, + config.model_provider_id.as_str(), + Some("needle"), + ) + .await?; + assert_eq!(repaired_state_db_only_page.items.len(), 0); + Ok(()) +} + #[tokio::test] async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 7d72429c99..ae87bff73e 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -191,6 +191,7 @@ pub async fn list_threads_db( sort_direction: SortDirection, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, + cwd_filters: Option<&[PathBuf]>, archived: bool, search_term: Option<&str>, ) -> Option { @@ -213,6 +214,12 @@ pub async fn list_threads_db( }) .collect(); let model_providers = model_providers.map(<[String]>::to_vec); + let normalized_cwd_filters = cwd_filters.map(|filters| { + filters + .iter() + .map(|cwd| normalize_cwd_for_state_db(cwd)) + .collect::>() + }); match ctx .list_threads( page_size, @@ -220,6 +227,7 @@ pub async fn list_threads_db( archived_only: archived, allowed_sources: allowed_sources.as_slice(), model_providers: model_providers.as_deref(), + cwd_filters: normalized_cwd_filters.as_deref(), anchor: anchor.as_ref(), sort_key: match sort_key { ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index d9619a598a..5769a3d576 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -540,6 +540,7 @@ async fn test_list_conversations_latest_first() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -689,6 +690,7 @@ async fn test_pagination_cursor() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -756,6 +758,7 @@ async fn test_pagination_cursor() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -823,6 +826,7 @@ async fn test_pagination_cursor() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -877,6 +881,7 @@ async fn test_list_threads_scans_past_head_for_user_event() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -910,6 +915,7 @@ async fn test_get_thread_contents() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -1000,6 +1006,7 @@ async fn test_base_instructions_missing_in_meta_defaults_to_null() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -1043,6 +1050,7 @@ async fn test_base_instructions_present_in_meta_is_preserved() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -1101,6 +1109,7 @@ async fn test_created_at_sort_uses_file_mtime_for_updated_at() -> Result<()> { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await?; @@ -1186,6 +1195,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { ThreadSortKey::UpdatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await?; @@ -1247,6 +1257,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -1315,6 +1326,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -1363,6 +1375,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { ThreadSortKey::CreatedAt, INTERACTIVE_SESSION_SOURCES.as_slice(), Some(provider_filter.as_slice()), + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -1385,6 +1398,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, /*model_providers*/ None, + /*cwd_filters*/ None, TEST_PROVIDER, ) .await @@ -1447,6 +1461,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(openai_filter.as_slice()), + /*cwd_filters*/ None, "openai", ) .await?; @@ -1467,6 +1482,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(beta_filter.as_slice()), + /*cwd_filters*/ None, "openai", ) .await?; @@ -1486,6 +1502,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, Some(unknown_filter.as_slice()), + /*cwd_filters*/ None, "openai", ) .await?; @@ -1498,6 +1515,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<( ThreadSortKey::CreatedAt, NO_SOURCE_FILTER, /*model_providers*/ None, + /*cwd_filters*/ None, "openai", ) .await?; diff --git a/codex-rs/state/migrations/0027_threads_cwd_sort_indexes.sql b/codex-rs/state/migrations/0027_threads_cwd_sort_indexes.sql new file mode 100644 index 0000000000..8b1e5c58ff --- /dev/null +++ b/codex-rs/state/migrations/0027_threads_cwd_sort_indexes.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_threads_archived_cwd_created_at_ms ON threads(archived, cwd, created_at_ms DESC, id DESC); +CREATE INDEX idx_threads_archived_cwd_updated_at_ms ON threads(archived, cwd, updated_at_ms DESC, id DESC); diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index eee3a2c7b1..b9cf432e43 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -175,6 +175,7 @@ LEFT JOIN jobs archived_only: false, allowed_sources, model_providers: None, + cwd_filters: None, anchor: None, sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Desc, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 70fea73ff4..8a3bcee5b3 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -359,6 +359,7 @@ ON CONFLICT(child_thread_id) DO NOTHING archived_only, allowed_sources, model_providers, + cwd_filters: None, anchor: None, sort_key: crate::SortKey::UpdatedAt, sort_direction: SortDirection::Desc, @@ -437,6 +438,7 @@ ON CONFLICT(child_thread_id) DO NOTHING archived_only, allowed_sources, model_providers, + cwd_filters: None, anchor, sort_key, sort_direction: SortDirection::Desc, @@ -1002,6 +1004,7 @@ pub struct ThreadFilterOptions<'a> { pub archived_only: bool, pub allowed_sources: &'a [String], pub model_providers: Option<&'a [String]>, + pub cwd_filters: Option<&'a [PathBuf]>, pub anchor: Option<&'a crate::Anchor>, pub sort_key: SortKey, pub sort_direction: SortDirection, @@ -1016,6 +1019,7 @@ pub(super) fn push_thread_filters<'a>( archived_only, allowed_sources, model_providers, + cwd_filters, anchor, sort_key, sort_direction, @@ -1046,6 +1050,20 @@ pub(super) fn push_thread_filters<'a>( } separated.push_unseparated(")"); } + match cwd_filters { + Some([]) => { + builder.push(" AND 1 = 0"); + } + Some(cwd_filters) => { + builder.push(" AND threads.cwd IN ("); + let mut separated = builder.separated(", "); + for cwd in cwd_filters { + separated.push_bind(cwd.display().to_string()); + } + separated.push_unseparated(")"); + } + None => {} + } if let Some(search_term) = search_term { builder.push(" AND instr(threads.title, "); builder.push_bind(search_term); @@ -1188,6 +1206,7 @@ mod tests { archived_only: false, allowed_sources: &[], model_providers: Some(&model_providers), + cwd_filters: None, anchor: Some(&anchor), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Asc, @@ -1214,6 +1233,7 @@ mod tests { archived_only: false, allowed_sources: &[], model_providers: Some(&model_providers), + cwd_filters: None, anchor: page.next_anchor.as_ref(), sort_key: SortKey::UpdatedAt, sort_direction: SortDirection::Asc, @@ -1228,6 +1248,77 @@ mod tests { assert_eq!(page.next_anchor, None); } + #[tokio::test] + async fn list_threads_filters_by_cwd() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("state db should initialize"); + let first_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread id"); + let second_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000102").expect("valid thread id"); + let other_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000103").expect("valid thread id"); + let first_cwd = codex_home.join("first"); + let second_cwd = codex_home.join("second"); + let other_cwd = codex_home.join("other"); + + for (thread_id, cwd, updated_at) in [ + (first_id, first_cwd.clone(), 1_700_000_100), + (second_id, second_cwd.clone(), 1_700_000_300), + (other_id, other_cwd, 1_700_000_500), + ] { + let mut metadata = test_thread_metadata(&codex_home, thread_id, cwd); + metadata.updated_at = + DateTime::::from_timestamp(updated_at, 0).expect("valid timestamp"); + runtime + .upsert_thread(&metadata) + .await + .expect("thread insert should succeed"); + } + + let cwd_filters = vec![first_cwd, second_cwd]; + let page = runtime + .list_threads( + /*page_size*/ 10, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: Some(cwd_filters.as_slice()), + anchor: None, + sort_key: SortKey::UpdatedAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("list should succeed"); + + let ids = page.items.iter().map(|item| item.id).collect::>(); + assert_eq!(ids, vec![second_id, first_id]); + + let page = runtime + .list_threads( + /*page_size*/ 10, + ThreadFilterOptions { + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: Some(&[]), + anchor: None, + sort_key: SortKey::UpdatedAt, + sort_direction: SortDirection::Desc, + search_term: None, + }, + ) + .await + .expect("list with empty cwd filters should succeed"); + + assert_eq!(page.items, Vec::new()); + } + #[tokio::test] async fn apply_rollout_items_restores_memory_mode_from_session_meta() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index dc07e49627..0dd65df72a 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -103,8 +103,10 @@ mod tests { sort_direction: crate::SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, + cwd_filters: None, archived: true, search_term: None, + use_state_db_only: false, }) .await .expect("archived listing"); diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index a0c2d4131e..d68ebd47f6 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -68,7 +68,35 @@ async fn list_rollout_threads( sort_key: codex_rollout::ThreadSortKey, sort_direction: codex_rollout::SortDirection, ) -> ThreadStoreResult { - let page = if params.archived { + let page = if params.use_state_db_only && params.archived { + RolloutRecorder::list_archived_threads_from_state_db( + config, + params.page_size, + cursor, + sort_key, + sort_direction, + params.allowed_sources.as_slice(), + params.model_providers.as_deref(), + params.cwd_filters.as_deref(), + config.model_provider_id.as_str(), + params.search_term.as_deref(), + ) + .await + } else if params.use_state_db_only { + RolloutRecorder::list_threads_from_state_db( + config, + params.page_size, + cursor, + sort_key, + sort_direction, + params.allowed_sources.as_slice(), + params.model_providers.as_deref(), + params.cwd_filters.as_deref(), + config.model_provider_id.as_str(), + params.search_term.as_deref(), + ) + .await + } else if params.archived { RolloutRecorder::list_archived_threads( config, params.page_size, @@ -77,6 +105,7 @@ async fn list_rollout_threads( sort_direction, params.allowed_sources.as_slice(), params.model_providers.as_deref(), + params.cwd_filters.as_deref(), config.model_provider_id.as_str(), params.search_term.as_deref(), ) @@ -90,6 +119,7 @@ async fn list_rollout_threads( sort_direction, params.allowed_sources.as_slice(), params.model_providers.as_deref(), + params.cwd_filters.as_deref(), config.model_provider_id.as_str(), params.search_term.as_deref(), ) @@ -140,8 +170,10 @@ mod tests { sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, + cwd_filters: None, archived: false, search_term: None, + use_state_db_only: false, }) .await .expect("thread listing"); @@ -196,8 +228,10 @@ mod tests { sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, + cwd_filters: None, archived: false, search_term: Some("needle".to_string()), + use_state_db_only: true, }) .await .expect("thread listing"); @@ -233,8 +267,10 @@ mod tests { sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, + cwd_filters: None, archived: false, search_term: None, + use_state_db_only: false, }) .await .expect("active listing"); @@ -246,8 +282,10 @@ mod tests { sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, + cwd_filters: None, archived: true, search_term: None, + use_state_db_only: false, }) .await .expect("archived listing"); @@ -295,8 +333,10 @@ mod tests { sort_direction: SortDirection::Desc, allowed_sources: vec![SessionSource::Cli], model_providers: Some(vec!["test-provider".to_string()]), + cwd_filters: None, archived: false, search_term: None, + use_state_db_only: false, }) .await .expect("thread listing"); @@ -329,8 +369,10 @@ mod tests { sort_direction: SortDirection::Desc, allowed_sources: Vec::new(), model_providers: None, + cwd_filters: None, archived: false, search_term: None, + use_state_db_only: false, }) .await .expect_err("invalid cursor should fail"); diff --git a/codex-rs/thread-store/src/remote/list_threads.rs b/codex-rs/thread-store/src/remote/list_threads.rs index 0ed1f44a1b..cbccb792f7 100644 --- a/codex-rs/thread-store/src/remote/list_threads.rs +++ b/codex-rs/thread-store/src/remote/list_threads.rs @@ -30,8 +30,15 @@ pub(super) async fn list_threads( model_provider_filter: params .model_providers .map(|values| proto::ModelProviderFilter { values }), + cwd_filter: params.cwd_filters.map(|values| proto::CwdFilter { + values: values + .into_iter() + .map(|cwd| cwd.display().to_string()) + .collect(), + }), archived: params.archived, search_term: params.search_term, + use_state_db_only: params.use_state_db_only, }; let response = store @@ -91,12 +98,19 @@ mod tests { ); assert_eq!(request.archived, true); assert_eq!(request.search_term.as_deref(), Some("needle")); + assert!(request.use_state_db_only); assert_eq!( request.model_provider_filter, Some(proto::ModelProviderFilter { values: vec!["openai".to_string()], }) ); + assert_eq!( + request.cwd_filter, + Some(proto::CwdFilter { + values: vec!["/workspace".to_string()], + }) + ); assert_eq!(request.allowed_sources.len(), 1); assert_eq!( proto::SessionSourceKind::try_from(request.allowed_sources[0].kind), @@ -164,8 +178,10 @@ mod tests { sort_direction: crate::SortDirection::Desc, allowed_sources: vec![SessionSource::Cli], model_providers: Some(vec!["openai".to_string()]), + cwd_filters: Some(vec![PathBuf::from("/workspace")]), archived: true, search_term: Some("needle".to_string()), + use_state_db_only: true, }) .await .expect("list threads"); diff --git a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto index f217027fcd..267cb93206 100644 --- a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto +++ b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.proto @@ -14,12 +14,18 @@ message ListThreadsRequest { optional ModelProviderFilter model_provider_filter = 5; bool archived = 6; optional string search_term = 7; + optional CwdFilter cwd_filter = 8; + bool use_state_db_only = 9; } message ModelProviderFilter { repeated string values = 1; } +message CwdFilter { + repeated string values = 1; +} + enum ThreadSortKey { THREAD_SORT_KEY_CREATED_AT = 0; THREAD_SORT_KEY_UPDATED_AT = 1; diff --git a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs index 6d0e410268..73262bc4a5 100644 --- a/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs +++ b/codex-rs/thread-store/src/remote/proto/codex.thread_store.v1.rs @@ -17,12 +17,21 @@ pub struct ListThreadsRequest { pub archived: bool, #[prost(string, optional, tag = "7")] pub search_term: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, optional, tag = "8")] + pub cwd_filter: ::core::option::Option, + #[prost(bool, tag = "9")] + pub use_state_db_only: bool, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ModelProviderFilter { #[prost(string, repeated, tag = "1")] pub values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CwdFilter { + #[prost(string, repeated, tag = "1")] + pub values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListThreadsResponse { #[prost(message, repeated, tag = "1")] diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 2145adc3d9..1f12bea64a 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -128,10 +128,15 @@ pub struct ListThreadsParams { /// Optional model provider filter. `None` means implementation default, while an empty vector /// means all providers. pub model_providers: Option>, + /// Optional cwd filters. `None` means all working directories, while an empty vector matches no + /// threads. + pub cwd_filters: Option>, /// Whether archived threads should be listed instead of active threads. pub archived: bool, /// Optional substring/full-text search term for thread title/preview. pub search_term: Option, + /// Return directly from the state DB without scanning JSONL rollouts to repair metadata. + pub use_state_db_only: bool, } /// A page of stored thread records. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7e33f2e8a3..8814cddda3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::Account as AppServerAccount; use codex_app_server_protocol::AuthMode as AppServerAuthMode; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::Thread as AppServerThread; +use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; @@ -523,6 +524,7 @@ async fn lookup_session_target_by_name_with_app_server( source_kinds: Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), cwd: None, + use_state_db_only: false, search_term: Some(name.to_string()), }) .await?; @@ -614,7 +616,8 @@ fn latest_session_lookup_params( source_kinds: (!include_non_interactive) .then_some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), - cwd: cwd_filter.map(|cwd| cwd.to_string_lossy().to_string()), + cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())), + use_state_db_only: false, search_term: None, } } @@ -1883,7 +1886,10 @@ mod tests { ); assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); - assert_eq!(params.cwd, Some(cwd.to_string_lossy().to_string())); + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) + ); Ok(()) } @@ -1918,7 +1924,10 @@ mod tests { ); assert_eq!(params.model_providers, None); - assert_eq!(params.cwd.as_deref(), Some("repo/on/server")); + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One(String::from("repo/on/server"))) + ); Ok(()) } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 94d5401d24..fe0825ccd8 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -14,6 +14,7 @@ use crate::tui::TuiEvent; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; @@ -1002,7 +1003,8 @@ fn thread_list_params( source_kinds: (!include_non_interactive) .then_some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), - cwd: cwd_filter.map(|cwd| cwd.to_string_lossy().to_string()), + cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())), + use_state_db_only: false, search_term: None, } } @@ -1573,7 +1575,10 @@ mod tests { params.source_kinds, Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]) ); - assert_eq!(params.cwd.as_deref(), Some("repo/on/server")); + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One(String::from("repo/on/server"))) + ); } #[test] From 639382609f8a96b4bf255fa2e735e8fb1aca4531 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 22 Apr 2026 11:16:17 +0100 Subject: [PATCH 06/11] fix: wait_agent timeout for queued mailbox mail (#18968) ## Why `wait_agent` can be called while mailbox mail is already pending. The previous implementation subscribed for future mailbox sequence changes and then waited for the next notification. If the mail was queued before that wait started, no new notification arrived, so the tool could sit until `timeout_ms` even though mail was ready to deliver. ## What Changed - Added `Session::has_pending_mailbox_items()` for checking pending mailbox mail through the session API. - Updated `multi_agents_v2::wait` to return immediately when pending mailbox mail already exists before sleeping on a new mailbox sequence update. - Reworked the regression coverage in `multi_agents_tests.rs` so already queued mailbox mail must wake `wait_agent` promptly. Relevant code: - [`wait_agent` pending-mail check](https://github.com/openai/codex/blob/aa8ca06e83cf2a3dc22f86f37caec6cc2d9533ea/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs#L55-L60) - [`Session::has_pending_mailbox_items`](https://github.com/openai/codex/blob/aa8ca06e83cf2a3dc22f86f37caec6cc2d9533ea/codex-rs/core/src/session/mod.rs#L2979-L2981) - [`multi_agent_v2_wait_agent_returns_for_already_queued_mail`](https://github.com/openai/codex/blob/aa8ca06e83cf2a3dc22f86f37caec6cc2d9533ea/codex-rs/core/src/tools/handlers/multi_agents_tests.rs#L2854) ## Verification - `cargo test -p codex-core multi_agent_v2_wait_agent_returns_for_already_queued_mail` --- codex-rs/core/src/session/mod.rs | 6 ++- .../src/tools/handlers/multi_agents_tests.rs | 49 ++++++------------- .../tools/handlers/multi_agents_v2/wait.rs | 8 ++- 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 2efb59cdfa..d06666dd19 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2976,6 +2976,10 @@ impl Session { self.mailbox_rx.lock().await.has_pending_trigger_turn() } + pub(crate) async fn has_pending_mailbox_items(&self) -> bool { + self.mailbox_rx.lock().await.has_pending() + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -3075,7 +3079,7 @@ impl Session { if !accepts_mailbox_delivery { return false; } - self.mailbox_rx.lock().await.has_pending() + self.has_pending_mailbox_items().await } pub async fn interrupt_task(self: &Arc) { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index b886664e35..d7117dcc4d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2851,7 +2851,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_waits_for_new_mail_after_start() { +async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -2896,46 +2896,25 @@ async fn multi_agent_v2_wait_agent_waits_for_new_mail_after_start() { .expect("worker path"); session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path.clone(), + worker_path, AgentPath::root(), Vec::new(), "already queued".to_string(), /*trigger_turn*/ false, )); - let wait_task = tokio::spawn({ - let session = session.clone(); - let turn = turn.clone(); - async move { - WaitAgentHandlerV2 - .handle(invocation( - session, - turn, - "wait_agent", - function_payload(json!({"timeout_ms": 1000})), - )) - .await - } - }); - tokio::task::yield_now().await; - tokio::time::sleep(Duration::from_millis(50)).await; - assert!( - !wait_task.is_finished(), - "mail already queued before wait should not wake wait_agent" - ); - - session.enqueue_mailbox_communication(InterAgentCommunication::new( - worker_path, - AgentPath::root(), - Vec::new(), - "new mail".to_string(), - /*trigger_turn*/ false, - )); - - let output = wait_task - .await - .expect("wait task should join") - .expect("wait_agent should succeed"); + let output = timeout( + Duration::from_millis(500), + WaitAgentHandlerV2.handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({"timeout_ms": 1000})), + )), + ) + .await + .expect("already queued mail should complete wait_agent immediately") + .expect("wait_agent should succeed"); let (content, success) = expect_text_output(output); let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 777fe1c2e7..e50c6cab23 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -52,8 +52,12 @@ impl ToolHandler for Handler { ) .await; - let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); - let timed_out = !wait_for_mailbox_change(&mut mailbox_seq_rx, deadline).await; + let timed_out = if session.has_pending_mailbox_items().await { + false + } else { + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + !wait_for_mailbox_change(&mut mailbox_seq_rx, deadline).await + }; let result = WaitAgentResult::from_timed_out(timed_out); session From ddf65c96479bc9bcad91d69e6ed8febd8753545f Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 22 Apr 2026 11:46:11 +0100 Subject: [PATCH 07/11] fix: cargo deny (#18971) --- MODULE.bazel.lock | 13 +++++------ codex-rs/.cargo/audit.toml | 1 - codex-rs/Cargo.lock | 44 ++++++++++++++++++++++++-------------- codex-rs/deny.toml | 1 - 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index eda359e2a0..7c0b30feba 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -669,15 +669,16 @@ "aws-sdk-ssooidc_1.93.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", "aws-sdk-sts_1.95.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"test-util\",\"wire-mock\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"name\":\"aws-smithy-query\",\"req\":\"^0.60.9\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-smithy-xml\",\"req\":\"^0.60.13\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.25\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", "aws-sigv4_1.3.7": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\",\"hardcoded-credentials\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"client\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"crypto-bigint\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"form_urlencoded\",\"optional\":true,\"req\":\"^1.2.1\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"name\":\"hmac\",\"req\":\"^0.12\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"http0\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"httparse\",\"req\":\"^1.10.1\"},{\"features\":[\"ecdsa\"],\"name\":\"p256\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.3.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.5\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.5\",\"target\":\"cfg(not(any(target_arch = \\\"powerpc\\\", target_arch = \\\"powerpc64\\\")))\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.104\"},{\"name\":\"sha2\",\"req\":\"^0.10\"},{\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.5.0\"},{\"name\":\"time\",\"req\":\"^0.3.5\"},{\"features\":[\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.5\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7.0\"}],\"features\":{\"default\":[\"sign-http\",\"http1\"],\"http0-compat\":[\"dep:http0\"],\"http1\":[\"dep:http\"],\"sign-eventstream\":[\"dep:aws-smithy-eventstream\"],\"sign-http\":[\"dep:http0\",\"dep:percent-encoding\",\"dep:form_urlencoded\"],\"sigv4a\":[\"dep:p256\",\"dep:crypto-bigint\",\"dep:subtle\",\"dep:zeroize\",\"dep:ring\"]}}", - "aws-smithy-async_1.2.7": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.40.0\"},{\"features\":[\"rt\",\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.2\"}],\"features\":{\"rt-tokio\":[\"tokio/time\"],\"test-util\":[\"rt-tokio\",\"tokio/rt\"]}}", - "aws-smithy-http-client_1.1.5": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-protocol-test\",\"optional\":true,\"req\":\"^0.63.7\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"http-body-0-4-x\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"h2\",\"req\":\"^0.4.11\"},{\"name\":\"h2-0-3\",\"optional\":true,\"package\":\"h2\",\"req\":\"^0.3.24\"},{\"name\":\"http-02x\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body-04x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.2\"},{\"features\":[\"client\",\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.6.0\"},{\"default_features\":false,\"features\":[\"client\",\"http1\",\"http2\",\"tcp\",\"stream\"],\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"default_features\":false,\"features\":[\"http2\",\"http1\",\"native-tokio\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.16\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.16\"},{\"features\":[\"serde\"],\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.10.0\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\",\"logging\",\"acceptor\",\"tokio-runtime\",\"http2\"],\"name\":\"legacy-hyper-rustls\",\"optional\":true,\"package\":\"hyper-rustls\",\"req\":\"^0.24.2\"},{\"name\":\"legacy-rustls\",\"optional\":true,\"package\":\"rustls\",\"req\":\"^0.21.8\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.31\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.1\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2.2.0\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.12.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rustls-pki-types\",\"req\":\"^1.12.0\"},{\"name\":\"s2n-tls\",\"optional\":true,\"req\":\"^0.3.24\"},{\"name\":\"s2n-tls-hyper\",\"optional\":true,\"req\":\"^0.0.16\"},{\"name\":\"s2n-tls-tokio\",\"optional\":true,\"req\":\"^0.3.24\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.210\"},{\"features\":[\"preserve_order\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.128\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.2\"},{\"name\":\"tokio\",\"req\":\"^1.40\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.2\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26.2\"},{\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5.2\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"}],\"features\":{\"default-client\":[\"aws-smithy-runtime-api/http-1x\",\"aws-smithy-types/http-body-1-x\",\"dep:hyper\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"hyper-util?/client-proxy\",\"dep:http-1x\",\"dep:tower\",\"dep:rustls-pki-types\",\"dep:rustls-native-certs\"],\"hyper-014\":[\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\",\"dep:http-02x\",\"dep:http-body-04x\",\"dep:hyper-0-14\",\"dep:h2-0-3\"],\"legacy-rustls-ring\":[\"dep:legacy-hyper-rustls\",\"dep:legacy-rustls\",\"dep:rustls-native-certs\",\"hyper-014\"],\"legacy-test-util\":[\"test-util\",\"dep:http-02x\",\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\"],\"rustls-aws-lc\":[\"dep:rustls\",\"rustls?/aws_lc_rs\",\"rustls?/prefer-post-quantum\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"rustls-aws-lc-fips\":[\"dep:rustls\",\"rustls?/fips\",\"rustls?/prefer-post-quantum\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"s2n-tls\":[\"dep:s2n-tls\",\"dep:s2n-tls-hyper\",\"dep:s2n-tls-tokio\",\"default-client\"],\"test-util\":[\"dep:aws-smithy-protocol-test\",\"dep:serde\",\"dep:serde_json\",\"dep:indexmap\",\"dep:bytes\",\"dep:http-1x\",\"aws-smithy-runtime-api/http-1x\",\"dep:http-body-1x\",\"aws-smithy-types/http-body-1-x\",\"tokio/rt\"],\"wire-mock\":[\"test-util\",\"default-client\",\"hyper-util?/server\",\"hyper-util?/server-auto\",\"hyper-util?/service\",\"hyper-util?/server-graceful\",\"tokio/macros\",\"dep:http-body-util\"]}}", + "aws-smithy-async_1.2.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"features\":[\"rt\",\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.2\"}],\"features\":{\"rt-tokio\":[\"tokio/time\"],\"test-util\":[\"rt-tokio\",\"tokio/rt\"]}}", + "aws-smithy-http-client_1.1.12": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-protocol-test\",\"optional\":true,\"req\":\"^0.63.14\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.11.6\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"features\":[\"http-body-0-4-x\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.4.6\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.11.1\"},{\"default_features\":false,\"name\":\"h2\",\"req\":\"^0.4.11\"},{\"name\":\"h2-0-3\",\"optional\":true,\"package\":\"h2\",\"req\":\"^0.3.24\"},{\"name\":\"http-02x\",\"optional\":true,\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-04x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1.3\"},{\"features\":[\"client\",\"http1\",\"http2\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1.6.0\"},{\"default_features\":false,\"features\":[\"client\",\"http1\",\"http2\",\"tcp\",\"stream\"],\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"default_features\":false,\"features\":[\"http2\",\"http1\",\"native-tokio\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"http1\",\"http2\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1.16\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.16\"},{\"features\":[\"serde\"],\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.10.0\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\",\"logging\",\"acceptor\",\"tokio-runtime\",\"http2\"],\"name\":\"legacy-hyper-rustls\",\"optional\":true,\"package\":\"hyper-rustls\",\"req\":\"^0.24.2\"},{\"name\":\"legacy-rustls\",\"optional\":true,\"package\":\"rustls\",\"req\":\"^0.21.8\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.31\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.1\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2.2.0\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.12.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rustls-pki-types\",\"req\":\"^1.12.0\"},{\"name\":\"s2n-tls\",\"optional\":true,\"req\":\"^0.3.33\"},{\"name\":\"s2n-tls-hyper\",\"optional\":true,\"req\":\"^0.1.0\"},{\"name\":\"s2n-tls-tokio\",\"optional\":true,\"req\":\"^0.3.33\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.228\"},{\"features\":[\"preserve_order\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.146\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.2\"},{\"name\":\"tokio\",\"req\":\"^1.49\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.2\"},{\"kind\":\"dev\",\"name\":\"tokio-rustls\",\"req\":\"^0.26.2\"},{\"name\":\"tower\",\"optional\":true,\"req\":\"^0.5.2\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"}],\"features\":{\"default-client\":[\"aws-smithy-runtime-api/http-1x\",\"aws-smithy-types/http-body-1-x\",\"dep:hyper\",\"dep:hyper-util\",\"hyper-util?/client-legacy\",\"hyper-util?/client-proxy\",\"dep:http-1x\",\"dep:tower\",\"dep:rustls-pki-types\",\"dep:rustls-native-certs\"],\"hyper-014\":[\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\",\"dep:http-02x\",\"dep:http-body-04x\",\"dep:hyper-0-14\",\"dep:h2-0-3\"],\"legacy-rustls-ring\":[\"dep:legacy-hyper-rustls\",\"dep:legacy-rustls\",\"dep:rustls-native-certs\",\"hyper-014\"],\"legacy-test-util\":[\"test-util\",\"dep:http-02x\",\"aws-smithy-runtime-api/http-02x\",\"aws-smithy-types/http-body-0-4-x\"],\"rustls-aws-lc\":[\"dep:rustls\",\"rustls?/aws_lc_rs\",\"rustls?/prefer-post-quantum\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"rustls-aws-lc-fips\":[\"dep:rustls\",\"rustls?/fips\",\"rustls?/prefer-post-quantum\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"default-client\"],\"s2n-tls\":[\"dep:s2n-tls\",\"dep:s2n-tls-hyper\",\"dep:s2n-tls-tokio\",\"default-client\"],\"test-util\":[\"dep:aws-smithy-protocol-test\",\"dep:serde\",\"dep:serde_json\",\"dep:indexmap\",\"dep:bytes\",\"dep:http-1x\",\"aws-smithy-runtime-api/http-1x\",\"dep:http-body-1x\",\"aws-smithy-types/http-body-1-x\",\"tokio/rt\"],\"wire-mock\":[\"test-util\",\"default-client\",\"hyper-util?/server\",\"hyper-util?/server-auto\",\"hyper-util?/service\",\"hyper-util?/server-graceful\",\"tokio/macros\",\"dep:http-body-util\"]}}", "aws-smithy-http_0.62.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.14\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"byte-stream-poll-next\",\"http-body-0-4-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1\"},{\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14.26\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"}],\"features\":{\"event-stream\":[\"aws-smithy-eventstream\"],\"rt-tokio\":[\"aws-smithy-types/rt-tokio\"]}}", "aws-smithy-json_0.61.9": "{\"dependencies\":[{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", "aws-smithy-observability_0.1.5": "{\"dependencies\":[{\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.1.1\"}],\"features\":{}}", "aws-smithy-query_0.60.9": "{\"dependencies\":[{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"}],\"features\":{}}", - "aws-smithy-runtime-api_1.9.3": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.40.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7.0\"}],\"features\":{\"client\":[],\"default\":[],\"http-02x\":[],\"http-1x\":[],\"http-auth\":[\"dep:zeroize\"],\"test-util\":[\"aws-smithy-types/test-util\",\"http-1x\"]}}", - "aws-smithy-runtime_1.9.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-http-client\",\"optional\":true,\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-observability\",\"req\":\"^0.1.5\"},{\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"http-body-0-4-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1x\",\"package\":\"http-body\",\"req\":\"^1\"},{\"features\":[\"client\",\"server\",\"tcp\",\"http1\",\"http2\"],\"kind\":\"dev\",\"name\":\"hyper_0_14\",\"package\":\"hyper\",\"req\":\"^0.14.27\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"name\":\"tokio\",\"req\":\"^1.40.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"features\":[\"env-filter\",\"fmt\",\"json\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3.16\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.1\"}],\"features\":{\"client\":[\"aws-smithy-runtime-api/client\",\"aws-smithy-types/http-body-1-x\"],\"connector-hyper-0-14-x\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/hyper-014\"],\"default-https-client\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/rustls-aws-lc\"],\"http-auth\":[\"aws-smithy-runtime-api/http-auth\"],\"legacy-test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"connector-hyper-0-14-x\",\"aws-smithy-http-client/legacy-test-util\"],\"rt-tokio\":[\"tokio/rt\"],\"test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"legacy-test-util\"],\"tls-rustls\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/legacy-rustls-ring\",\"connector-hyper-0-14-x\"],\"wire-mock\":[\"legacy-test-util\",\"aws-smithy-http-client/wire-mock\"]}}", - "aws-smithy-types_1.3.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.13.0\"},{\"name\":\"base64-simd\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"ciborium\",\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body-0-4\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1-0\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"name\":\"itoa\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"num-integer\",\"req\":\"^0.1.44\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ryu\",\"req\":\"^1.0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.210\",\"target\":\"cfg(aws_sdk_unstable)\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.16.0\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.40.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"fs\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1.5\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.1\"}],\"features\":{\"byte-stream-poll-next\":[],\"http-body-0-4-x\":[\"dep:http-body-0-4\",\"dep:http\"],\"http-body-1-x\":[\"dep:http-body-1-0\",\"dep:http-body-util\",\"dep:http-body-0-4\",\"dep:http-1x\",\"dep:http\"],\"hyper-0-14-x\":[\"dep:hyper-0-14\"],\"rt-tokio\":[\"dep:http-body-0-4\",\"dep:tokio-util\",\"dep:tokio\",\"tokio?/rt\",\"tokio?/fs\",\"tokio?/io-util\",\"tokio-util?/io\",\"dep:futures-core\",\"dep:http\"],\"serde-deserialize\":[],\"serde-serialize\":[],\"test-util\":[]}}", + "aws-smithy-runtime-api-macros_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.106\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.114\"}],\"features\":{}}", + "aws-smithy-runtime-api_1.12.0": "{\"dependencies\":[{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.14\"},{\"name\":\"aws-smithy-runtime-api-macros\",\"req\":\"^1.0.0\"},{\"features\":[\"http-body-1-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.4.7\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"name\":\"tracing\",\"req\":\"^0.1.44\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7.0\"}],\"features\":{\"client\":[],\"default\":[],\"http-02x\":[],\"http-1x\":[],\"http-auth\":[\"dep:zeroize\"],\"test-util\":[\"aws-smithy-types/test-util\",\"http-1x\"]}}", + "aws-smithy-runtime_1.9.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"rt-tokio\",\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-http-client\",\"optional\":true,\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-observability\",\"req\":\"^0.1.5\"},{\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"http-body-0-4-x\"],\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1x\",\"package\":\"http-body\",\"req\":\"^1\"},{\"features\":[\"client\",\"server\",\"tcp\",\"http1\",\"http2\"],\"kind\":\"dev\",\"name\":\"hyper_0_14\",\"package\":\"hyper\",\"req\":\"^0.14.27\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4.0\"},{\"name\":\"tokio\",\"req\":\"^1.40.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"test-util\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"features\":[\"env-filter\",\"fmt\",\"json\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3.16\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.1\"}],\"features\":{\"client\":[\"aws-smithy-runtime-api/client\",\"aws-smithy-types/http-body-1-x\"],\"connector-hyper-0-14-x\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/hyper-014\"],\"default-https-client\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/rustls-aws-lc\"],\"http-auth\":[\"aws-smithy-runtime-api/http-auth\"],\"legacy-test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"connector-hyper-0-14-x\",\"aws-smithy-http-client/legacy-test-util\"],\"rt-tokio\":[\"tokio/rt\"],\"test-util\":[\"aws-smithy-runtime-api/test-util\",\"dep:tracing-subscriber\",\"aws-smithy-http-client/test-util\",\"legacy-test-util\"],\"tls-rustls\":[\"dep:aws-smithy-http-client\",\"aws-smithy-http-client?/legacy-rustls-ring\",\"connector-hyper-0-14-x\"],\"wire-mock\":[\"legacy-test-util\",\"aws-smithy-http-client/wire-mock\"]}}", + "aws-smithy-types_1.4.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.13.0\"},{\"name\":\"base64-simd\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1.11.1\"},{\"name\":\"bytes-utils\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"ciborium\",\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3.31\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^0.2.12\"},{\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1.3.1\"},{\"name\":\"http-body-0-4\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^0.4.6\"},{\"name\":\"http-body-1-0\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1.3\"},{\"name\":\"hyper-0-14\",\"optional\":true,\"package\":\"hyper\",\"req\":\"^0.14.26\"},{\"name\":\"itoa\",\"req\":\"^1.0.17\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"num-integer\",\"req\":\"^0.1.44\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ryu\",\"req\":\"^1.0.22\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.228\",\"target\":\"cfg(aws_sdk_unstable)\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.16.0\"},{\"features\":[\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.4\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.49.0\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"fs\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.49.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1.5\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.18\"}],\"features\":{\"byte-stream-poll-next\":[],\"http-body-0-4-x\":[\"dep:http-body-0-4\",\"dep:http\"],\"http-body-1-x\":[\"dep:http-body-1-0\",\"dep:http-body-util\",\"dep:http-body-0-4\",\"dep:http\"],\"hyper-0-14-x\":[\"dep:hyper-0-14\"],\"rt-tokio\":[\"dep:http-body-0-4\",\"dep:tokio-util\",\"dep:tokio\",\"tokio?/rt\",\"tokio?/fs\",\"tokio?/io-util\",\"tokio-util?/io\",\"dep:futures-core\",\"dep:http\"],\"serde-deserialize\":[],\"serde-serialize\":[],\"test-util\":[]}}", "aws-smithy-xml_0.60.13": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"xmlparser\",\"req\":\"^0.13.5\"}],\"features\":{}}", "aws-types_1.3.11": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-runtime\",\"optional\":true,\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"http-02x\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"http2\",\"webpki-roots\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.24.2\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.16.0\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.5\"}],\"features\":{\"examples\":[\"dep:hyper-rustls\",\"aws-smithy-runtime/client\",\"aws-smithy-runtime/connector-hyper-0-14-x\",\"aws-smithy-runtime/tls-rustls\"]}}", "axum-core_0.4.5": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1.67\"},{\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.7.2\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.0\"},{\"name\":\"mime\",\"req\":\"^0.3.16\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"},{\"name\":\"rustversion\",\"req\":\"^1.0.9\"},{\"name\":\"sync_wrapper\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"limit\"],\"name\":\"tower-http\",\"optional\":true,\"req\":\"^0.6.0\"},{\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower-http\",\"req\":\"^0.6.0\"},{\"name\":\"tower-layer\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{\"__private_docs\":[\"dep:tower-http\"],\"tracing\":[\"dep:tracing\"]}}", @@ -1435,7 +1436,7 @@ "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", - "rustls-webpki_0.103.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", + "rustls-webpki_0.103.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", "rustyline_14.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.2\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"default_features\":false,\"name\":\"buffer-redux\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(unix)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"clipboard-win\",\"req\":\"^5.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fd-lock\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"home\",\"optional\":true,\"req\":\"^0.5.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"memchr\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"fs\",\"ioctl\",\"poll\",\"signal\",\"term\"],\"name\":\"nix\",\"req\":\"^0.28\",\"target\":\"cfg(unix)\"},{\"name\":\"radix_trie\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"bundled\",\"backup\"],\"name\":\"rusqlite\",\"optional\":true,\"req\":\"^0.31.0\"},{\"name\":\"rustyline-derive\",\"optional\":true,\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"signal-hook\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"skim\",\"optional\":true,\"req\":\"^0.10\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"name\":\"termios\",\"optional\":true,\"req\":\"^0.3.3\",\"target\":\"cfg(unix)\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1.0\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"},{\"name\":\"utf8parse\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Security\",\"Win32_System_Threading\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.52.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"case_insensitive_history_search\":[\"regex\"],\"custom-bindings\":[\"radix_trie\"],\"default\":[\"custom-bindings\",\"with-dirs\",\"with-file-history\"],\"derive\":[\"rustyline-derive\"],\"with-dirs\":[\"home\"],\"with-file-history\":[\"fd-lock\"],\"with-fuzzy\":[\"skim\"],\"with-sqlite-history\":[\"rusqlite\"]}}", diff --git a/codex-rs/.cargo/audit.toml b/codex-rs/.cargo/audit.toml index 3760d28648..4d9e4b81ed 100644 --- a/codex-rs/.cargo/audit.toml +++ b/codex-rs/.cargo/audit.toml @@ -6,5 +6,4 @@ ignore = [ "RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained "RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it "RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it - "RUSTSEC-2026-0097", # rand 0.8.5 via age/codex-secrets and zbus/keyring; remove when transitive deps move to rand >=0.9.3 ] diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 980f3b87a0..87b22c2b93 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -929,9 +929,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.7" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ee19095c7c4dda59f1697d028ce704c24b2d33c6718790c7f1d5a3015b4107c" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" dependencies = [ "futures-util", "pin-project-lite", @@ -961,9 +961,9 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.5" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e62db736db19c488966c8d787f52e6270be565727236fd5579eaa301e7bc4a" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1013,9 +1013,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.9.6" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fda37911905ea4d3141a01364bc5509a0f32ae3f3b22d6e330c0abfb62d247" +checksum = "a392db6c583ea4a912538afb86b7be7c5d8887d91604f50eb55c262ee1b4a5f5" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1037,11 +1037,12 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.9.3" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0d43d899f9e508300e587bf582ba54c27a452dd0a9ea294690669138ae14a2" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" dependencies = [ "aws-smithy-async", + "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", @@ -1053,10 +1054,21 @@ dependencies = [ ] [[package]] -name = "aws-smithy-types" -version = "1.3.5" +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905cb13a9895626d49cf2ced759b062d913834c7482c38e49557eac4e6193f01" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" dependencies = [ "base64-simd", "bytes", @@ -8624,7 +8636,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -9092,7 +9104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -10761,9 +10773,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -13726,7 +13738,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index bed4a4f2fa..b153ba80a8 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -78,7 +78,6 @@ ignore = [ # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, - { id = "RUSTSEC-2026-0097", reason = "rand 0.8.5 is pulled in via age v0.11.2/codex-secrets and zbus v4.4.0/keyring; no compatible rand 0.8 fixed release, remove when transitive dependencies move to rand >=0.9.3" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. From 65420737e80794d361351fb24675a27506ff09ac Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 22 Apr 2026 11:46:15 +0100 Subject: [PATCH 08/11] chore: prep memories for AB (#18973) --- codex-rs/app-server/src/config_api.rs | 1 + .../suite/v2/experimental_feature_list.rs | 29 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 5fac8d8386..df4ad24d63 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -38,6 +38,7 @@ use tracing::warn; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "apps", + "memories", "plugins", "tool_search", "tool_suggest", diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 3f7e6989f8..0c681e7fb9 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -129,18 +129,20 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("config.toml"), - "[features]\napps = false\n", + "[features]\nmemories = false\n", )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let actual = - set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) - .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("memories".to_string(), true)]), + ) + .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("apps".to_string(), true)]), + enablement: BTreeMap::from([("memories".to_string(), true)]), } ); @@ -150,7 +152,7 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> config .additional .get("features") - .and_then(|features| features.get("apps")), + .and_then(|features| features.get("memories")), Some(&json!(false)) ); @@ -168,6 +170,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([ + ("memories".to_string(), true), ("plugins".to_string(), true), ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), @@ -180,6 +183,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([ + ("memories".to_string(), true), ("plugins".to_string(), true), ("tool_search".to_string(), true), ("tool_suggest".to_string(), true), @@ -197,6 +201,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("apps")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("memories")), + Some(&json!(true)) + ); assert_eq!( config .additional @@ -285,9 +296,9 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - error.message ); assert!( - error - .message - .contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"), + error.message.contains( + "apps, memories, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation" + ), "{}", error.message ); From 0ebe69a8c33ed99d99ffa21c58524824ae4941ba Mon Sep 17 00:00:00 2001 From: Vaibhav Srivastav Date: Wed, 22 Apr 2026 20:38:10 +0530 Subject: [PATCH 09/11] [codex] Update imagegen system skill (#18852) ## Summary This updates the embedded `imagegen` system skill in `codex-rs/skills` with the ImageGen 2 skill changes from `openai/skills-internal#87`. The bundled skill now keeps normal image generation/editing on the built-in `image_gen` path, updates the CLI fallback defaults to `gpt-image-2`, and routes explicit transparent-output requests through `gpt-image-1.5` with clear guidance that `gpt-image-2` does not support transparent backgrounds. ## Details - Update `SKILL.md` routing guidance for built-in vs CLI fallback behavior. - Update CLI/API references for `gpt-image-2` size constraints, quality options, near-4K sizes, and unsupported options. - Update `scripts/image_gen.py` defaults and validation: - default model `gpt-image-2` - default size `auto` - default quality `medium` - reject transparent backgrounds on `gpt-image-2` - reject `input_fidelity` on `gpt-image-2` - validate flexible `gpt-image-2` sizes and suggest `3824x2160` / `2160x3824` for near-4K requests - Update prompt/reference docs with the new model and routing guidance. ## Validation - `cargo test -p codex-skills` - `git diff --check` - Manual CLI dry-runs for: - default `gpt-image-2` payload - `3824x2160` near-4K size acceptance - `3840x2160` rejection with near-4K guidance - transparent background rejection on `gpt-image-2` - transparent background acceptance on `gpt-image-1.5` - `input_fidelity` rejection on `gpt-image-2` Bazel target check was not run locally because `bazel` is not installed in this environment. --- .codespellignore | 1 + .../src/assets/samples/imagegen/SKILL.md | 109 ++++- .../samples/imagegen/agents/openai.yaml | 2 +- .../assets/samples/imagegen/references/cli.md | 100 +++- .../imagegen/references/codex-network.md | 2 +- .../samples/imagegen/references/image-api.md | 53 ++- .../samples/imagegen/references/prompting.md | 24 +- .../imagegen/references/sample-prompts.md | 63 ++- .../samples/imagegen/scripts/image_gen.py | 95 +++- .../imagegen/scripts/remove_chroma_key.py | 440 ++++++++++++++++++ 10 files changed, 838 insertions(+), 51 deletions(-) create mode 100644 codex-rs/skills/src/assets/samples/imagegen/scripts/remove_chroma_key.py diff --git a/.codespellignore b/.codespellignore index 947511b0c3..23924fe083 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1,5 +1,6 @@ iTerm iTerm2 psuedo +SOM te TE diff --git a/codex-rs/skills/src/assets/samples/imagegen/SKILL.md b/codex-rs/skills/src/assets/samples/imagegen/SKILL.md index 88195882ef..4285e5e641 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/SKILL.md +++ b/codex-rs/skills/src/assets/samples/imagegen/SKILL.md @@ -11,18 +11,22 @@ Generates or edits images for the current project (for example website assets, g This skill has exactly two top-level modes: -- **Default built-in tool mode (preferred):** built-in `image_gen` tool for normal image generation and editing. Does not require `OPENAI_API_KEY`. -- **Fallback CLI mode (explicit-only):** `scripts/image_gen.py` CLI. Use only when the user explicitly asks for the CLI path. Requires `OPENAI_API_KEY`. +- **Default built-in tool mode (preferred):** built-in `image_gen` tool for normal image generation, editing, and simple transparent-image requests. Does not require `OPENAI_API_KEY`. +- **Fallback CLI mode:** `scripts/image_gen.py` CLI. Use when the user explicitly asks for the CLI/API/model path, or after the user explicitly confirms a true model-native transparency fallback with `gpt-image-1.5`. Requires `OPENAI_API_KEY`. -Within the explicit CLI fallback only, the CLI exposes three subcommands: +Within CLI fallback, the CLI exposes three subcommands: - `generate` - `edit` - `generate-batch` Rules: -- Use the built-in `image_gen` tool by default for all normal image generation and editing requests. -- Never switch to CLI fallback automatically. +- Use the built-in `image_gen` tool by default for normal image generation and editing requests. +- Do not switch to CLI fallback for ordinary quality, size, or file-path control. +- If the user explicitly asks for a transparent image/background, stay on built-in `image_gen` first: prompt for a flat removable chroma-key background, then remove it locally with the installed helper at `$CODEX_HOME/skills/.system/imagegen/scripts/remove_chroma_key.py`. +- Never silently switch from built-in `image_gen` or CLI `gpt-image-2` to CLI `gpt-image-1.5`. Treat this as a model/path downgrade and ask the user before doing it, unless the user has already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. +- If a transparent request appears too complex for clean chroma-key removal, asks for true/native transparency, or local removal fails validation, explain that true transparency requires CLI `gpt-image-1.5 --background transparent --output-format png` because `gpt-image-2` does not support `background=transparent`, then ask whether to proceed. Run the CLI fallback only after the user confirms. +- The word `batch` by itself does not mean CLI fallback. If the user asks for many assets or says to batch-generate assets without explicitly asking for CLI/API/model controls, stay on the built-in path and issue one built-in call per requested asset or variant. - If the built-in tool fails or is unavailable, tell the user the CLI fallback exists and that it requires `OPENAI_API_KEY`. Proceed only if the user explicitly asks for that fallback. - If the user explicitly asks for CLI mode, use the bundled `scripts/image_gen.py` workflow. Do not create one-off SDK runners. - Never modify `scripts/image_gen.py`. If something is missing, ask the user before doing anything else. @@ -46,6 +50,9 @@ Fallback-only docs/resources for CLI mode: - `references/codex-network.md` - `scripts/image_gen.py` +Local post-processing helper: +- `$CODEX_HOME/skills/.system/imagegen/scripts/remove_chroma_key.py`: removes a flat chroma-key background from a generated image and writes a PNG/WebP with alpha. Prefer auto-key sampling, soft matte, and despill for antialiased edges. + ## When to use - Generate a new image (concept art, product shot, cover, website hero) - Generate a new image using one or more reference images for style, composition, or mood @@ -79,12 +86,13 @@ Built-in edit semantics: Execution strategy: - In the built-in default path, produce many assets or variants by issuing one `image_gen` call per requested asset or variant. -- In the explicit CLI fallback path, use the CLI `generate-batch` subcommand only when the user explicitly chose CLI mode and needs many prompts/assets. +- In the CLI fallback path, use the CLI `generate-batch` subcommand only when the user explicitly chose CLI mode and needs many prompts/assets. +- For many distinct assets, do not use `n` as a substitute for separate prompts. `n` is for variants of one prompt; distinct assets need distinct built-in calls or distinct CLI `generate-batch` jobs. Assume the user wants a new image unless they clearly ask to change an existing one. ## Workflow -1. Decide the top-level mode: built-in by default, fallback CLI only if explicitly requested. +1. Decide the top-level mode: built-in by default, including simple transparent-output requests; fallback CLI only if explicitly requested or after the user explicitly confirms a transparent-output fallback. 2. Decide the intent: `generate` or `edit`. 3. Decide whether the output is preview-only or meant to be consumed by the current project. 4. Decide the execution strategy: single asset vs repeated built-in calls vs CLI `generate-batch`. @@ -99,13 +107,54 @@ Assume the user wants a new image unless they clearly ask to change an existing - If the user's prompt is already specific and detailed, normalize it into a clear spec without adding creative requirements. - If the user's prompt is generic, add tasteful augmentation only when it materially improves output quality. 10. Use the built-in `image_gen` tool by default. -11. If the user explicitly chooses the CLI fallback, then and only then use the fallback-only docs for quality, `input_fidelity`, masks, output format, output paths, and network setup. +11. For transparent-output requests, follow the transparent image guidance below: generate with built-in `image_gen` on a flat chroma-key background, copy the selected output into the workspace or `tmp/imagegen/`, run the installed `$CODEX_HOME/skills/.system/imagegen/scripts/remove_chroma_key.py` helper, and validate the alpha result before using it. If this path looks unsuitable or fails, ask before switching to CLI `gpt-image-1.5`. 12. Inspect outputs and validate: subject, style, composition, text accuracy, and invariants/avoid items. 13. Iterate with a single targeted change, then re-check. 14. For preview-only work, render the image inline; the underlying file may remain at the default `$CODEX_HOME/generated_images/...` path. 15. For project-bound work, move or copy the selected artifact into the workspace and update any consuming code or references. Never leave a project-referenced asset only at the default `$CODEX_HOME/generated_images/...` path. -16. For batches, persist only the selected finals in the workspace unless the user explicitly asked to keep discarded variants. -17. Always report the final saved path for any workspace-bound asset, plus the final prompt and whether the built-in tool or fallback CLI mode was used. +16. For batches or multi-asset requests, persist every requested deliverable final in the workspace unless the user explicitly asked to keep outputs preview-only. Discarded variants do not need to be kept unless requested. +17. If the user explicitly chooses or confirms the CLI fallback, then use the fallback-only docs for model, quality, size, `input_fidelity`, masks, output format, output paths, and network setup. +18. Always report the final saved path(s) for any workspace-bound asset(s), plus the final prompt or prompt set and whether the built-in tool or fallback CLI mode was used. + +## Transparent image requests + +Transparent-image requests still use built-in `image_gen` first. Because the built-in tool does not expose a true transparent-background control, create a removable chroma-key source image and then convert the key color to alpha locally. + +Default sequence: +1. Use built-in `image_gen` to generate the requested subject on a perfectly flat solid chroma-key background. +2. Choose a key color that is unlikely to appear in the subject: default `#00ff00`, use `#ff00ff` for green subjects, and avoid `#0000ff` for blue subjects. +3. After generation, move or copy the selected source image from `$CODEX_HOME/generated_images/...` into the workspace or `tmp/imagegen/`. +4. Run the installed helper path, not a project-relative script path: + ```bash + python "${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/scripts/remove_chroma_key.py" \ + --input \ + --out \ + --auto-key border \ + --soft-matte \ + --transparent-threshold 12 \ + --opaque-threshold 220 \ + --despill + ``` +5. Validate that the output has an alpha channel, transparent corners, plausible subject coverage, and no obvious key-color fringe. If a thin fringe remains, retry once with `--edge-contract 1`; use `--edge-feather 0.25` only when the edge is visibly stair-stepped and the subject is not shiny or reflective. +6. Save the final alpha PNG/WebP in the project if the asset is project-bound. Never leave a project-referenced transparent asset only under `$CODEX_HOME/*`. + +Prompt transparent requests like this: + +```text +Create the requested subject on a perfectly flat solid #00ff00 chroma-key background for background removal. +The background must be one uniform color with no shadows, gradients, texture, reflections, floor plane, or lighting variation. +Keep the subject fully separated from the background with crisp edges and generous padding. +Do not use #00ff00 anywhere in the subject. +No cast shadow, no contact shadow, no reflection, no watermark, and no text unless explicitly requested. +``` + +Do not automatically use CLI `gpt-image-1.5 --background transparent --output-format png` instead of chroma keying. Ask the user first when the user asks for true/native transparency, when local removal fails validation, or when the requested image is complex: hair, fur, feathers, smoke, glass, liquids, translucent materials, reflective objects, soft shadows, realistic product grounding, or subject colors that conflict with all practical key colors. + +Use a concise confirmation like: + +```text +This likely needs true native transparency. The default built-in path uses a chroma-key background plus local removal, but true transparency requires the CLI fallback with gpt-image-1.5 because gpt-image-2 does not support background=transparent. It also requires OPENAI_API_KEY. Should I proceed with that CLI fallback? +``` ## Prompt augmentation @@ -140,6 +189,9 @@ Generate: - product-mockup — product/packaging shots, catalog imagery, merch concepts. - ui-mockup — app/web interface mockups and wireframes; specify the desired fidelity. - infographic-diagram — diagrams/infographics with structured layout and text. +- scientific-educational — classroom explainers, scientific diagrams, and learning visuals with required labels and accuracy constraints. +- ads-marketing — campaign concepts and ad creatives with audience, brand position, scene, and exact tagline/copy. +- productivity-visual — slide, chart, workflow, and data-heavy business visuals. - logo-brand — logo/mark exploration, vector-friendly. - illustration-story — comics, children’s book art, narrative scenes. - stylized-concept — style-driven concept art, 3D/stylized renders. @@ -150,7 +202,7 @@ Edit: - identity-preserve — try-on, person-in-scene; lock face/body/pose. - precise-object-edit — remove/replace a specific element (including interior swaps). - lighting-weather — time-of-day/season/atmosphere changes only. -- background-extraction — transparent background / clean cutout. +- background-extraction — transparent background / clean cutout. Use built-in `image_gen` with chroma-key removal first for simple opaque subjects; ask before using CLI true transparency for complex subjects. - style-transfer — apply reference style while changing subject/scene. - compositing — multi-image insert/merge with matched lighting/perspective. - sketch-to-render — drawing/line art to photoreal render. @@ -179,7 +231,7 @@ Avoid: Notes: - `Asset type` and `Input images` are prompt scaffolding, not dedicated CLI flags. - `Scene/backdrop` refers to the visual setting. It is not the same as the fallback CLI `background` parameter, which controls output transparency behavior. -- Fallback-only execution notes such as `Quality:`, `Input fidelity:`, masks, output format, and output paths belong in the explicit CLI path only. Do not treat them as built-in `image_gen` tool arguments. +- Fallback-only execution notes such as `Quality:`, `Input fidelity:`, masks, output format, and output paths belong in the CLI path only. Do not treat them as built-in `image_gen` tool arguments. Augmentation rules: - Keep it short. @@ -220,7 +272,8 @@ Constraints: change only the background; keep the product and its edges unchange - Iterate with single-change follow-ups. - If the prompt is generic, add only the extra detail that will materially help. - If the prompt is already detailed, normalize it instead of expanding it. -- For explicit CLI fallback only, see `references/cli.md` and `references/image-api.md` for `quality`, `input_fidelity`, masks, output format, and output-path guidance. +- For CLI fallback only, see `references/cli.md` and `references/image-api.md` for model, `quality`, `input_fidelity`, masks, output format, and output-path guidance. +- For transparent images, use the built-in-first chroma-key workflow unless the request is complex enough to need true CLI transparency; ask before switching to CLI `gpt-image-1.5`. More principles shared by both modes: `references/prompting.md`. Copy/paste specs shared by both modes: `references/sample-prompts.md`. @@ -228,10 +281,33 @@ Copy/paste specs shared by both modes: `references/sample-prompts.md`. ## Guidance by asset type Asset-type templates (website assets, game assets, wireframes, logo) are consolidated in `references/sample-prompts.md`. +## gpt-image-2 guidance for CLI fallback + +The fallback CLI defaults to `gpt-image-2`. + +- Use `gpt-image-2` for new CLI/API workflows unless the request needs true model-native transparent output. +- If a transparent request may need CLI fallback, ask before using `gpt-image-1.5` unless the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. Explain that the built-in chroma-key path is the default, but true transparency requires `gpt-image-1.5` because `gpt-image-2` does not support `background=transparent`. +- `gpt-image-2` always uses high fidelity for image inputs; do not set `input_fidelity` with this model. +- `gpt-image-2` supports `quality` values `low`, `medium`, `high`, and `auto`. +- Use `quality low` for fast drafts, thumbnails, and quick iterations. Use `medium`, `high`, or `auto` for final assets, dense text, diagrams, identity-sensitive edits, or high-resolution outputs. +- Square images are typically fastest to generate. Use `1024x1024` for fast square drafts. +- If the user asks for 4K-style output, use `3840x2160` for landscape or `2160x3840` for portrait. +- `gpt-image-2` size may be `auto` or `WIDTHxHEIGHT` if all constraints hold: max edge `<= 3840px`, both edges multiples of `16px`, long-to-short ratio `<= 3:1`, total pixels between `655,360` and `8,294,400`. + +Popular `gpt-image-2` sizes: +- `1024x1024` square +- `1536x1024` landscape +- `1024x1536` portrait +- `2048x2048` 2K square +- `2048x1152` 2K landscape +- `3840x2160` 4K landscape +- `2160x3840` 4K portrait +- `auto` + ## Fallback CLI mode only ### Temp and output conventions -These conventions apply only to the explicit CLI fallback. They do not describe built-in `image_gen` output behavior. +These conventions apply only to the CLI fallback. They do not describe built-in `image_gen` output behavior. - Use `tmp/imagegen/` for intermediate files (for example JSONL batches); delete them when done. - Write final artifacts under `output/imagegen/`. - Use `--out` or `--out-dir` to control output paths; keep filenames stable and descriptive. @@ -244,7 +320,7 @@ Required Python package: uv pip install openai ``` -Optional for downscaling only: +Required for local chroma-key removal and optional downscaling: ```bash uv pip install pillow ``` @@ -276,4 +352,5 @@ If installation is not possible in this environment, tell the user which depende - `references/cli.md`: fallback-only CLI usage via `scripts/image_gen.py`. - `references/image-api.md`: fallback-only API/CLI parameter reference. - `references/codex-network.md`: fallback-only network/sandbox troubleshooting for CLI mode. -- `scripts/image_gen.py`: fallback-only CLI implementation. Do not load or use it unless the user explicitly chooses CLI mode. +- `scripts/image_gen.py`: fallback-only CLI implementation. Do not load or use it unless the user explicitly chooses CLI mode or explicitly confirms a transparent request's true CLI transparency fallback. +- `$CODEX_HOME/skills/.system/imagegen/scripts/remove_chroma_key.py`: local post-processing helper for built-in transparent-image requests. diff --git a/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml b/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml index c9cfddb144..5e01d4410e 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml +++ b/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml @@ -3,4 +3,4 @@ interface: short_description: "Generate or edit images for websites, games, and more" icon_small: "./assets/imagegen-small.svg" icon_large: "./assets/imagegen.png" - default_prompt: "Generate or edit the visual assets for this task with the built-in `image_gen` tool by default. First confirm that the task actually calls for a raster image; if the project already has SVG/vector/code-native assets and the user wants to extend or match those, do not use this skill. If the task includes reference images, treat them as references unless the user clearly wants an existing image modified. For multi-asset requests, loop built-in calls rather than treating batch as a separate top-level mode. Only use the fallback CLI if the user explicitly asks for it, and keep CLI-only controls such as `generate-batch`, `quality`, `input_fidelity`, masks, and output paths on that fallback path." + default_prompt: "Use $imagegen to make or edit an image for this project." diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/cli.md b/codex-rs/skills/src/assets/samples/imagegen/references/cli.md index 8cb0663c57..f4a5a63d3d 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/references/cli.md +++ b/codex-rs/skills/src/assets/samples/imagegen/references/cli.md @@ -1,13 +1,14 @@ # CLI reference (`scripts/image_gen.py`) -This file is for the fallback CLI mode only. Read it only after the user explicitly asks to use `scripts/image_gen.py` instead of the built-in `image_gen` tool. +This file is for the fallback CLI mode only. Read it when the user explicitly asks to use `scripts/image_gen.py` / CLI / API / model controls, or after the user explicitly confirms that a transparent-output request should use the `gpt-image-1.5` true-transparency fallback path. `generate-batch` is a CLI subcommand in this fallback path. It is not a top-level mode of the skill. +The word `batch` in a user request is not CLI opt-in by itself. ## What this CLI does - `generate`: generate a new image from a prompt - `edit`: edit one or more existing images -- `generate-batch`: run many generation jobs from a JSONL file +- `generate-batch`: run many generation jobs from a JSONL file after the user explicitly chooses CLI/API/model controls Real API calls require **network access** + `OPENAI_API_KEY`. `--dry-run` does not. @@ -16,7 +17,7 @@ Set a stable path to the skill CLI (default `CODEX_HOME` is `~/.codex`): ``` export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" -export IMAGE_GEN="$CODEX_HOME/skills/imagegen/scripts/image_gen.py" +export IMAGE_GEN="$CODEX_HOME/skills/.system/imagegen/scripts/image_gen.py" ``` Install dependencies into that environment with its package manager. In uv-managed environments, `uv pip install ...` remains the preferred path. @@ -58,27 +59,102 @@ python "$IMAGE_GEN" edit \ - Use the bundled CLI directly (`python "$IMAGE_GEN" ...`) after activating the correct environment. - Do **not** create one-off runners (for example `gen_images.py`) unless the user explicitly asks for a custom wrapper. - **Never modify** `scripts/image_gen.py`. If something is missing, ask the user before doing anything else. +- Do not silently downgrade from CLI `gpt-image-2` or built-in `image_gen` to CLI `gpt-image-1.5`; ask first unless the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. ## Defaults -- Model: `gpt-image-1.5` +- Model: `gpt-image-2` - Supported model family for this CLI: GPT Image models (`gpt-image-*`) -- Size: `1024x1024` -- Quality: `auto` +- Size: `auto` +- Quality: `medium` - Output format: `png` - Default one-off output path: `output/imagegen/output.png` - Background: unspecified unless `--background` is set +## gpt-image-2 size and model guidance + +`gpt-image-2` is the default model for new CLI fallback work. + +- Use `--quality low` for fast drafts, thumbnails, and quick iterations. +- Use `--quality medium`, `--quality high`, or `--quality auto` for final assets, dense text, diagrams, identity-sensitive edits, and high-resolution outputs. +- Square images are typically fastest. Use `--size 1024x1024` for quick square drafts. +- If the user asks for 4K-style output, use `--size 3840x2160` for landscape or `--size 2160x3840` for portrait. +- Do not pass `--input-fidelity` with `gpt-image-2`; this model always uses high fidelity for image inputs. +- Do not use `--background transparent` with `gpt-image-2`; the default transparent-image workflow uses built-in `image_gen` on a flat chroma-key background plus local removal. Use `gpt-image-1.5` only after the user explicitly confirms the true-transparent CLI fallback, unless they already requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. + +Popular `gpt-image-2` sizes: +- `1024x1024` +- `1536x1024` +- `1024x1536` +- `2048x2048` +- `2048x1152` +- `3840x2160` +- `2160x3840` +- `auto` + +`gpt-image-2` size constraints: +- max edge `<= 3840px` +- both edges multiples of `16px` +- long edge to short edge ratio `<= 3:1` +- total pixels between `655,360` and `8,294,400` +- outputs above `2560x1440` total pixels are experimental + +Fast draft: + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A product thumbnail of a matte ceramic mug on a stone surface" \ + --quality low \ + --size 1024x1024 \ + --out output/imagegen/mug-draft.png +``` + +Final 2K landscape: + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A polished landing-page hero image of a matte ceramic mug on a stone surface" \ + --quality high \ + --size 2048x1152 \ + --out output/imagegen/mug-hero.png +``` + +4K landscape: + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A detailed architectural visualization at golden hour" \ + --size 3840x2160 \ + --quality high \ + --out output/imagegen/architecture-4k.png +``` + +True transparent fallback request: + +Ask for confirmation before using this command unless the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. + +```bash +python "$IMAGE_GEN" generate \ + --model gpt-image-1.5 \ + --prompt "A clean product cutout on a transparent background" \ + --background transparent \ + --output-format png \ + --out output/imagegen/product-cutout.png +``` + +When using this path, explain briefly that built-in `image_gen` plus chroma-key removal is the default transparent-image path, but this request needs true model-native transparency. `gpt-image-2` does not support `background=transparent`, so `gpt-image-1.5` is required for this confirmed fallback. + ## Quality, input fidelity, and masks (CLI fallback only) These are explicit CLI controls. They are not built-in `image_gen` tool arguments. - `--quality` works for `generate`, `edit`, and `generate-batch`: `low|medium|high|auto` -- `--input-fidelity` is **edit-only** and validated as `low|high` +- `--input-fidelity` is **edit-only** and validated as `low|high`; it is not supported for `gpt-image-2` - `--mask` is **edit-only** Example: ```bash python "$IMAGE_GEN" edit \ + --model gpt-image-1.5 \ --image input.png \ --prompt "Change only the background" \ --quality high \ @@ -89,6 +165,10 @@ python "$IMAGE_GEN" edit \ Mask notes: - For multi-image edits, pass repeated `--image` flags. Their order is meaningful, so describe each image by index and role in the prompt. - The CLI accepts a single `--mask`. +- Image and mask must be the same size and format and each under 50MB. +- Masks must include an alpha channel. +- If multiple input images are provided, the mask applies to the first image. +- Masking is prompt-guided; do not promise exact pixel-perfect mask boundaries. - Use a PNG mask when possible; the script treats mask handling as best-effort and does not perform full preflight validation beyond file checks/warnings. - In the edit prompt, repeat invariants (`change only the background; keep the subject unchanged`) to reduce drift. @@ -147,10 +227,11 @@ Notes: - Per-job overrides are supported in JSONL (for example `size`, `quality`, `background`, `output_format`, `output_compression`, `moderation`, `n`, `model`, `out`, and prompt-augmentation fields). - `--n` generates multiple variants for a single prompt; `generate-batch` is for many different prompts. - In batch mode, per-job `out` is treated as a filename under `--out-dir`. +- For many requested deliverable assets, provide one prompt/job per distinct asset and use semantic filenames when possible. ## CLI notes -- Supported sizes: `1024x1024`, `1536x1024`, `1024x1536`, or `auto`. -- Transparent backgrounds require `output_format` to be `png` or `webp`. +- Supported sizes depend on the model. `gpt-image-2` supports flexible constrained sizes; older GPT Image models support `1024x1024`, `1536x1024`, `1024x1536`, or `auto`. +- True transparent CLI outputs require `output_format` to be `png` or `webp` and are not supported by `gpt-image-2`. - `--prompt-file`, `--output-compression`, `--moderation`, `--max-attempts`, `--fail-fast`, `--force`, and `--no-augment` are supported. - This CLI is intended for GPT Image models. Do not assume older non-GPT image-model behavior applies here. @@ -158,3 +239,4 @@ Notes: - API parameter quick reference for fallback CLI mode: `references/image-api.md` - Prompt examples shared across both top-level modes: `references/sample-prompts.md` - Network/sandbox notes for fallback CLI mode: `references/codex-network.md` +- Built-in-first transparent image workflow: `SKILL.md` and `$CODEX_HOME/skills/.system/imagegen/scripts/remove_chroma_key.py` diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md b/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md index 249d628262..5ce1fbc748 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md +++ b/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md @@ -1,6 +1,6 @@ # Codex network approvals / sandbox notes -This file is for the fallback CLI mode only. Read it only after the user explicitly asks to use `scripts/image_gen.py`. +This file is for the fallback CLI mode only. Read it when the user explicitly asks to use `scripts/image_gen.py` / CLI / API / model controls, or after the user explicitly confirms that a transparent-output request should use the `gpt-image-1.5` true-transparency fallback path. This guidance is intentionally isolated from `SKILL.md` because it can vary by environment and may become stale. Prefer the defaults in your environment when in doubt. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md b/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md index a2750c15c6..db8567de2f 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md +++ b/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md @@ -1,13 +1,46 @@ # Image API quick reference -This file is for the fallback CLI mode only. Use it only after the user explicitly asks to use `scripts/image_gen.py` instead of the built-in `image_gen` tool. +This file is for the fallback CLI mode only. Use it when the user explicitly asks to use `scripts/image_gen.py` / CLI / API / model controls, or after the user explicitly confirms that a transparent-output request should use the `gpt-image-1.5` true-transparency fallback path. These parameters describe the Image API and bundled CLI fallback surface. Do not assume they are normal arguments on the built-in `image_gen` tool. ## Scope -- This fallback CLI is intended for GPT Image models (`gpt-image-1.5`, `gpt-image-1`, and `gpt-image-1-mini`). +- This fallback CLI is intended for GPT Image models (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, and `gpt-image-1-mini`). - The built-in `image_gen` tool and the fallback CLI do not expose the same controls. +## Model summary + +| Model | Quality | Input fidelity | Resolutions | Recommended use | +| --- | --- | --- | --- | --- | +| `gpt-image-2` | `low`, `medium`, `high`, `auto` | Always high fidelity for image inputs; do not set `input_fidelity` | `auto` or flexible sizes that satisfy the constraints below | Default for new CLI/API workflows: high-quality generation and editing, text-heavy images, photorealism, compositing, identity-sensitive edits, and workflows where fewer retries matter | +| `gpt-image-1.5` | `low`, `medium`, `high`, `auto` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | True transparent-background fallback and backward-compatible workflows | +| `gpt-image-1` | `low`, `medium`, `high`, `auto` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | Legacy compatibility | +| `gpt-image-1-mini` | `low`, `medium`, `high`, `auto` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | Cost-sensitive draft batches and lower-stakes previews | + +## gpt-image-2 sizes + +`gpt-image-2` accepts `auto` or any `WIDTHxHEIGHT` size that satisfies all constraints: + +- Maximum edge length must be less than or equal to `3840px`. +- Both edges must be multiples of `16px`. +- Long edge to short edge ratio must not exceed `3:1`. +- Total pixels must be at least `655,360` and no more than `8,294,400`. + +Popular sizes: + +| Label | Size | Notes | +| --- | --- | --- | +| Square | `1024x1024` | Typical fast default | +| Landscape | `1536x1024` | Standard landscape | +| Portrait | `1024x1536` | Standard portrait | +| 2K square | `2048x2048` | Larger square output | +| 2K landscape | `2048x1152` | Widescreen output | +| 4K landscape | `3840x2160` | Widescreen 4K output | +| 4K portrait | `2160x3840` | Vertical 4K output | +| Auto | `auto` | Default size | + +Square images are typically fastest to generate. For 4K-style output, use `3840x2160` or `2160x3840`. + ## Endpoints - Generate: `POST /v1/images/generations` (`client.images.generate(...)`) - Edit: `POST /v1/images/edits` (`client.images.edit(...)`) @@ -16,7 +49,7 @@ These parameters describe the Image API and bundled CLI fallback surface. Do not - `prompt`: text prompt - `model`: image model - `n`: number of images (1-10) -- `size`: `1024x1024`, `1536x1024`, `1024x1536`, or `auto` +- `size`: `auto` by default for `gpt-image-2`; flexible `WIDTHxHEIGHT` sizes are allowed only for `gpt-image-2`; older GPT Image models use `1024x1024`, `1536x1024`, `1024x1536`, or `auto` - `quality`: `low`, `medium`, `high`, or `auto` - `background`: output transparency behavior (`transparent`, `opaque`, or `auto`) for generated output; this is not the same thing as the prompt's visual scene/backdrop - `output_format`: `png` (default), `jpeg`, `webp` @@ -26,12 +59,19 @@ These parameters describe the Image API and bundled CLI fallback surface. Do not ## Edit-specific parameters - `image`: one or more input images. For GPT Image models, you can provide up to 16 images. - `mask`: optional mask image -- `input_fidelity`: `low` (default) or `high` +- `input_fidelity`: `low` or `high` only for models that support it; do not set this for `gpt-image-2` Model-specific note for `input_fidelity`: +- `gpt-image-2` always uses high fidelity for image inputs and does not support setting `input_fidelity`. - `gpt-image-1` and `gpt-image-1-mini` preserve all input images, but the first image gets richer textures and finer details. - `gpt-image-1.5` preserves the first 5 input images with higher fidelity. +## Transparent backgrounds + +`gpt-image-2` does not currently support the Image API `background=transparent` parameter. The skill's default transparent-image path is built-in `image_gen` with a flat chroma-key background, followed by local alpha extraction with `python "${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/scripts/remove_chroma_key.py"`. + +Use CLI `gpt-image-1.5` with `background=transparent` and a transparent-capable output format such as `png` or `webp` only after the user explicitly confirms that fallback, unless they already requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. If the user asks for true/native transparency, the subject is too complex for clean chroma-key removal, or local background removal fails validation, explain the tradeoff and ask before switching. + ## Output - `data[]` list with `b64_json` per image - The bundled `scripts/image_gen.py` CLI decodes `b64_json` and writes output files for you. @@ -41,8 +81,9 @@ Model-specific note for `input_fidelity`: - Use the edits endpoint when the user requests changes to an existing image. - Masking is prompt-guided; exact shapes are not guaranteed. - Large sizes and high quality increase latency and cost. -- High `input_fidelity` can materially increase input token usage. -- If a request fails because a specific option is unsupported by the selected GPT Image model, retry manually without that option. +- Use `quality=low` for fast drafts, thumbnails, and quick iterations. Use `medium` or `high` for final assets, dense text, diagrams, identity-sensitive edits, or high-resolution outputs. +- High `input_fidelity` can materially increase input token usage on models that support it. +- If a request fails because a specific option is unsupported by the selected GPT Image model, retry manually without that option only when the option is not required by the user. If true transparent CLI output is required, ask before switching to `gpt-image-1.5` instead of dropping `background=transparent`, unless the user already explicitly chose that fallback. ## Important boundary - `quality`, `input_fidelity`, explicit masks, `background`, `output_format`, and related parameters are fallback-only execution controls. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md b/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md index 8b6c684eef..9d2da42f19 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md +++ b/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md @@ -15,6 +15,7 @@ This file is about prompt structure, specificity, and iteration. Fallback-only e - [Text in images](#text-in-images) - [Input images and references](#input-images-and-references) - [Iterate deliberately](#iterate-deliberately) +- [Transparent images](#transparent-images) - [Fallback-only execution controls](#fallback-only-execution-controls) - [Use-case tips](#use-case-tips) - [Where to find copy/paste recipes](#where-to-find-copypaste-recipes) @@ -28,6 +29,7 @@ This file is about prompt structure, specificity, and iteration. Fallback-only e - If the user prompt is already specific and detailed, normalize it into a clean spec without adding creative requirements. - If the prompt is generic, you may add tasteful detail when it materially improves the output. - Treat examples in `sample-prompts.md` as fully-authored recipes, not as the default amount of augmentation to add to every request. +- For photorealism, include `photorealistic` directly when that is the goal, plus concrete real-world texture such as pores, wrinkles, fabric wear, material grain, or imperfect everyday detail. ## Allowed and disallowed augmentation @@ -46,6 +48,7 @@ Do not add: - Specify framing and viewpoint (close-up, wide, top-down) and placement only when it materially helps. - Call out negative space if the asset clearly needs room for UI or copy. - Avoid making left/right layout decisions unless the user or surrounding layout supports them. +- For people, describe body framing, scale, gaze, and object interactions when they matter (`full body visible`, `looking down at the book`, `hands naturally gripping the handlebars`). ## Constraints and invariants - State what must not change (`keep background unchanged`). @@ -55,6 +58,7 @@ Do not add: - Put literal text in quotes or ALL CAPS and specify typography (font style, size, color, placement). - Spell uncommon words letter-by-letter if accuracy matters. - For in-image copy, require verbatim rendering and no extra characters. +- In CLI fallback mode, use `medium` or `high` quality for small text, dense infographics, data-heavy slides, multi-font layouts, legends, axes, and footnotes. ## Input images and references - Do not assume that every provided image is an edit target. @@ -68,18 +72,34 @@ Do not add: - Re-specify critical constraints when you iterate. - Prefer one targeted follow-up at a time over rewriting the whole prompt. +## Transparent images +- Use built-in `image_gen` first for transparent-image requests. If the subject is clearly too complex for chroma-key removal, explain the fallback and ask before switching to CLI. +- Prompt for a perfectly flat solid chroma-key background, usually `#00ff00`; use `#ff00ff` when the subject is green, and avoid key colors that appear in the subject. +- Explicitly prohibit shadows, gradients, floor planes, reflections, texture, and lighting variation in the background. +- Ask for crisp edges, generous padding, and no use of the key color inside the subject. +- After generation, remove the background locally with `python "${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/scripts/remove_chroma_key.py" --input --out --auto-key border --soft-matte --transparent-threshold 12 --opaque-threshold 220 --despill` and validate the alpha result before shipping it. +- Use soft matte and despill for antialiased edges; hard tolerance-only removal is mainly for flat pixel-art or exact-color fixtures. +- Use CLI `gpt-image-1.5 --background transparent --output-format png` only after the user explicitly confirms the fallback, or when the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. Ask first for true/native transparency requests, failed chroma-key validation, or complex transparent subjects such as hair, fur, glass, smoke, liquids, translucent materials, reflective objects, or soft shadows. + ## Fallback-only execution controls - `quality`, `input_fidelity`, explicit masks, output format, and output paths are fallback-only execution controls. - Do not assume they are built-in `image_gen` tool arguments. - If the user explicitly chooses CLI fallback, see `references/cli.md` and `references/image-api.md` for those controls. +- In CLI fallback mode, `gpt-image-2` is the default. It supports `quality=low|medium|high|auto`; use `low` for fast drafts and thumbnails, and move to `medium`, `high`, or `auto` for final assets. +- `gpt-image-2` always uses high fidelity for image inputs, so do not set `input_fidelity` with that model. +- If a transparent request needs true CLI transparency, ask before using `gpt-image-1.5` unless the user already explicitly chose it. Explain that built-in chroma-key removal is the default path, but `gpt-image-2` does not support `background=transparent`. +- If the user asks for 4K-style output with `gpt-image-2`, use `3840x2160` for landscape or `2160x3840` for portrait. ## Use-case tips Generate: - photorealistic-natural: Prompt as if a real photo is captured in the moment; use photography language (lens, lighting, framing); call for real texture; avoid over-stylized polish unless requested. - product-mockup: Describe the product/packaging and materials; ensure clean silhouette and label clarity; if in-image text is needed, require verbatim rendering and specify typography. - ui-mockup: Describe the target fidelity first (shippable mockup or low-fi wireframe), then focus on layout, hierarchy, and practical UI elements; avoid concept-art language. -- infographic-diagram: Define the audience and layout flow; label parts explicitly; require verbatim text. +- infographic-diagram: Define the audience and layout flow; label parts explicitly; require verbatim text; prefer higher quality in CLI mode for dense labels. - logo-brand: Keep it simple and scalable; ask for a strong silhouette and balanced negative space; avoid decorative flourishes unless requested. +- ads-marketing: Write like a creative brief; include brand positioning, audience, desired vibe, scene, and exact tagline if text must appear. +- productivity-visual: Name the exact artifact (slide, chart, workflow diagram), define the canvas and hierarchy, provide real labels/data, and ask for readable typography and polished spacing. +- scientific-educational: Define audience, lesson objective, required labels, scientific constraints, arrows, and scan-friendly whitespace. - illustration-story: Define panels or scene beats; keep each action concrete. - stylized-concept: Specify style cues, material finish, and rendering approach (3D, painterly, clay) without inventing new story elements. - historical-scene: State the location/date and required period accuracy; constrain clothing, props, and environment to match the era. @@ -89,7 +109,7 @@ Edit: - identity-preserve: Lock identity (face, body, pose, hair, expression); change only the specified elements; match lighting and shadows. - precise-object-edit: Specify exactly what to remove/replace; preserve surrounding texture and lighting; keep everything else unchanged. - lighting-weather: Change only environmental conditions (light, shadows, atmosphere, precipitation); keep geometry, framing, and subject identity. -- background-extraction: Request a clean cutout; crisp silhouette; no halos; preserve label text exactly; no restyling. +- background-extraction: For simple opaque subjects, request a clean cutout on a perfectly flat chroma-key background; crisp silhouette; generous padding; no shadows; no halos; preserve label text exactly; no restyling. Ask before using true CLI transparency for complex subjects. - style-transfer: Specify style cues to preserve (palette, texture, brushwork) and what must change; add `no extra elements` to prevent drift. - compositing: Reference inputs by index; specify what moves where; match lighting, perspective, and scale; keep the base framing unchanged. - sketch-to-render: Preserve layout, proportions, and perspective; choose materials and lighting that support the supplied sketch without adding new elements. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md b/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md index e4b2b01da0..d94929555e 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md +++ b/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md @@ -2,7 +2,7 @@ These prompt recipes are shared across both top-level modes of the skill: - built-in `image_gen` tool (default) -- explicit `scripts/image_gen.py` CLI fallback +- `scripts/image_gen.py` CLI fallback for explicit CLI/API/model requests or user-confirmed true-transparent-output fallback requests Use these as starting points. They are intentionally complete prompt recipes, not the default amount of augmentation to add to every user request. @@ -13,7 +13,14 @@ When adapting a user's prompt: The labeled lines are prompt scaffolding, not a closed schema. `Asset type` and `Input images` are prompt-only scaffolding; the CLI does not expose them as dedicated flags. -Execution details such as explicit CLI flags, `quality`, `input_fidelity`, masks, output formats, and local output paths depend on mode. Use the built-in tool by default; only apply CLI-specific controls after the user explicitly opts into fallback mode. +Execution details such as explicit CLI flags, `quality`, `input_fidelity`, masks, output formats, and local output paths depend on mode. Use the built-in tool by default, including simple transparent-image requests. For transparent images, prompt for a flat chroma-key background and remove it locally with `python "${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/scripts/remove_chroma_key.py"`; only apply CLI-specific controls when the user explicitly opts into fallback mode or explicitly confirms that the transparent request should use true CLI transparency. + +CLI model notes: +- `gpt-image-2` is the fallback CLI default for new workflows. +- `gpt-image-2` supports `quality` values `low`, `medium`, `high`, and `auto`. +- For 4K-style `gpt-image-2` output, use `3840x2160` or `2160x3840`. +- If transparent output needs true CLI fallback, ask before using `gpt-image-1.5` unless the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. Explain that built-in chroma-key removal is the default path, but `gpt-image-2` does not support `background=transparent`. +- Do not set `input_fidelity` with `gpt-image-2`; image inputs already use high fidelity. For prompting principles (structure, specificity, invariants, iteration), see `references/prompting.md`. @@ -68,6 +75,18 @@ Text (verbatim): "Bean Hopper", "Grinder", "Brew Group", "Boiler", "Water Tank", Constraints: clear labels, strong contrast, no logos or trademarks, no watermark ``` +### scientific-educational +``` +Use case: scientific-educational +Primary request: biology diagram titled "Cellular Respiration at a Glance" for high school students +Scene/backdrop: clean white classroom handout background +Subject: glucose turns into energy inside a cell; include glycolysis, Krebs cycle, and electron transport chain +Style/medium: flat scientific diagram with consistent icons, arrows, and readable labels +Composition/framing: landscape slide-style layout with clear hierarchy and generous whitespace +Text (verbatim): "Cellular Respiration at a Glance", "Glucose", "Pyruvate", "ATP", "NADH", "FADH2", "CO2", "O2", "H2O" +Constraints: scientifically plausible; avoid tiny text; no extra decoration; no watermark +``` + ### logo-brand ``` Use case: logo-brand @@ -100,6 +119,30 @@ Lighting/mood: volumetric light rays cutting through fog Constraints: no logos or trademarks; no watermark ``` +### ads-marketing +``` +Use case: ads-marketing +Primary request: campaign image for a streetwear brand called Thread +Subject: group of friends hanging out together in a stylish urban setting +Style/medium: polished youth streetwear campaign photography +Composition/framing: vertical ad layout with natural poses and integrated headline space +Lighting/mood: contemporary, energetic, tasteful +Text (verbatim): "Yours to Create." +Constraints: render the tagline exactly once; clean legible typography; no extra text; no watermarks; no unrelated logos +``` + +### productivity-visual +``` +Use case: productivity-visual +Primary request: one pitch-deck slide titled "Market Opportunity" +Asset type: fundraising slide image +Style/medium: clean modern deck slide, white background, crisp sans-serif typography +Subject: TAM/SAM/SOM concentric-circle diagram plus a small growth bar chart from 2021 to 2026 +Composition/framing: 16:9 landscape slide, clear data hierarchy, polished spacing +Text (verbatim): "Market Opportunity", "TAM: $42B", "SAM: $8.7B", "SOM: $340M", "AGI Research, 2024", "Internal analysis" +Constraints: readable labels, no clip art, no stock photography, no decorative clutter, no watermark +``` + ### historical-scene ``` Use case: historical-scene @@ -348,9 +391,12 @@ Constraints: preserve subject identity, geometry, camera angle, and composition; Use case: background-extraction Input images: Image 1: product photo Primary request: isolate the product on a clean transparent background -Constraints: crisp silhouette; no halos or fringing; preserve label text exactly; no restyling +Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for local background removal +Constraints: background must be one uniform color with no shadows, gradients, texture, reflections, floor plane, or lighting variation; crisp silhouette; generous padding; no halos or fringing; preserve label text exactly; no restyling; do not use #00ff00 anywhere in the subject ``` +Post-process note: after built-in generation, run `python "${CODEX_HOME:-$HOME/.codex}/skills/.system/imagegen/scripts/remove_chroma_key.py" --input --out --auto-key border --soft-matte --transparent-threshold 12 --opaque-threshold 220 --despill`. Ask before using CLI `gpt-image-1.5 --background transparent --output-format png` for true/native transparency, failed chroma-key validation, or complex subjects such as hair, fur, glass, smoke, liquids, translucent materials, reflections, or soft shadows, unless the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. + ### style-transfer ``` Use case: style-transfer @@ -367,6 +413,17 @@ Primary request: place the subject from Image 2 next to the person in Image 1 Constraints: match lighting, perspective, and scale; keep the base framing unchanged; no extra elements ``` +### character consistency workflow +``` +Use case: identity-preserve +Input images: Image 1: previous character anchor illustration +Primary request: continue the story with the same character in a new scene and action +Scene/backdrop: snowy forest after a winter storm +Subject: same young forest hero gently helping a frightened squirrel out of a fallen tree +Style/medium: same children's book watercolor illustration style as Image 1 +Constraints: do not redesign the character; preserve facial features, proportions, outfit, color palette, and personality; no text; no watermark +``` + ### sketch-to-render ``` Use case: sketch-to-render diff --git a/codex-rs/skills/src/assets/samples/imagegen/scripts/image_gen.py b/codex-rs/skills/src/assets/samples/imagegen/scripts/image_gen.py index 57cab20e05..9e0ea6717c 100644 --- a/codex-rs/skills/src/assets/samples/imagegen/scripts/image_gen.py +++ b/codex-rs/skills/src/assets/samples/imagegen/scripts/image_gen.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Fallback CLI for explicit image generation or editing with GPT Image models. -Used only when the user explicitly opts into CLI fallback mode. +Used only when the user explicitly opts into CLI fallback mode, or when explicit +transparent output requires the `gpt-image-1.5` fallback path. -Defaults to gpt-image-1.5 and a structured prompt augmentation workflow. +Defaults to gpt-image-2 and a structured prompt augmentation workflow. """ from __future__ import annotations @@ -21,20 +22,26 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple from io import BytesIO -DEFAULT_MODEL = "gpt-image-1.5" -DEFAULT_SIZE = "1024x1024" -DEFAULT_QUALITY = "auto" +DEFAULT_MODEL = "gpt-image-2" +DEFAULT_SIZE = "auto" +DEFAULT_QUALITY = "medium" DEFAULT_OUTPUT_FORMAT = "png" DEFAULT_CONCURRENCY = 5 DEFAULT_DOWNSCALE_SUFFIX = "-web" DEFAULT_OUTPUT_PATH = "output/imagegen/output.png" GPT_IMAGE_MODEL_PREFIX = "gpt-image-" -ALLOWED_SIZES = {"1024x1024", "1536x1024", "1024x1536", "auto"} +ALLOWED_LEGACY_SIZES = {"1024x1024", "1536x1024", "1024x1536", "auto"} ALLOWED_QUALITIES = {"low", "medium", "high", "auto"} ALLOWED_BACKGROUNDS = {"transparent", "opaque", "auto", None} ALLOWED_INPUT_FIDELITIES = {"low", "high", None} +GPT_IMAGE_2_MODEL = "gpt-image-2" +GPT_IMAGE_2_MIN_PIXELS = 655_360 +GPT_IMAGE_2_MAX_PIXELS = 8_294_400 +GPT_IMAGE_2_MAX_EDGE = 3840 +GPT_IMAGE_2_MAX_RATIO = 3.0 + MAX_IMAGE_BYTES = 50 * 1024 * 1024 MAX_BATCH_JOBS = 500 @@ -104,10 +111,46 @@ def _normalize_output_format(fmt: Optional[str]) -> str: return "jpeg" if fmt == "jpg" else fmt -def _validate_size(size: str) -> None: - if size not in ALLOWED_SIZES: +def _parse_size(size: str) -> Optional[Tuple[int, int]]: + match = re.fullmatch(r"([1-9][0-9]*)x([1-9][0-9]*)", size) + if not match: + return None + return int(match.group(1)), int(match.group(2)) + + +def _validate_gpt_image_2_size(size: str) -> None: + if size == "auto": + return + + parsed = _parse_size(size) + if parsed is None: + _die("size must be auto or WIDTHxHEIGHT, for example 1024x1024.") + + width, height = parsed + max_edge = max(width, height) + min_edge = min(width, height) + total_pixels = width * height + + if max_edge > GPT_IMAGE_2_MAX_EDGE: + _die("gpt-image-2 size maximum edge length must be less than or equal to 3840px.") + if width % 16 != 0 or height % 16 != 0: + _die("gpt-image-2 size width and height must be multiples of 16px.") + if max_edge / min_edge > GPT_IMAGE_2_MAX_RATIO: + _die("gpt-image-2 size long edge to short edge ratio must not exceed 3:1.") + if total_pixels < GPT_IMAGE_2_MIN_PIXELS or total_pixels > GPT_IMAGE_2_MAX_PIXELS: _die( - "size must be one of 1024x1024, 1536x1024, 1024x1536, or auto for GPT image models." + "gpt-image-2 size total pixels must be at least 655,360 and no more than 8,294,400." + ) + + +def _validate_size(size: str, model: str) -> None: + if model == GPT_IMAGE_2_MODEL: + _validate_gpt_image_2_size(size) + return + + if size not in ALLOWED_LEGACY_SIZES: + _die( + "size must be one of 1024x1024, 1536x1024, 1024x1536, or auto for this GPT Image model." ) @@ -138,17 +181,38 @@ def _validate_transparency(background: Optional[str], output_format: str) -> Non _die("transparent background requires output-format png or webp.") +def _validate_model_specific_options( + *, + model: str, + background: Optional[str], + input_fidelity: Optional[str] = None, +) -> None: + if model != GPT_IMAGE_2_MODEL: + return + if background == "transparent": + _die( + "transparent backgrounds are not supported in gpt-image-2, the latest model. " + "Use --model gpt-image-1.5 --background transparent --output-format png instead." + ) + if input_fidelity is not None: + _die( + "input_fidelity is not supported in gpt-image-2 because image inputs always use high fidelity for this model." + ) + + def _validate_generate_payload(payload: Dict[str, Any]) -> None: - _validate_model(str(payload.get("model", DEFAULT_MODEL))) + model = str(payload.get("model", DEFAULT_MODEL)) + _validate_model(model) n = int(payload.get("n", 1)) if n < 1 or n > 10: _die("n must be between 1 and 10") size = str(payload.get("size", DEFAULT_SIZE)) quality = str(payload.get("quality", DEFAULT_QUALITY)) background = payload.get("background") - _validate_size(size) + _validate_size(size, model) _validate_quality(quality) _validate_background(background) + _validate_model_specific_options(model=model, background=background) oc = payload.get("output_compression") if oc is not None and not (0 <= int(oc) <= 100): _die("output_compression must be between 0 and 100") @@ -912,10 +976,15 @@ def main() -> int: if getattr(args, "downscale_max_dim", None) is not None and args.downscale_max_dim < 1: _die("--downscale-max-dim must be >= 1") - _validate_size(args.size) + _validate_model(args.model) + _validate_size(args.size, args.model) _validate_quality(args.quality) _validate_background(args.background) - _validate_model(args.model) + _validate_model_specific_options( + model=args.model, + background=args.background, + input_fidelity=getattr(args, "input_fidelity", None), + ) _ensure_api_key(args.dry_run) args.func(args) diff --git a/codex-rs/skills/src/assets/samples/imagegen/scripts/remove_chroma_key.py b/codex-rs/skills/src/assets/samples/imagegen/scripts/remove_chroma_key.py new file mode 100644 index 0000000000..50539877b0 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/scripts/remove_chroma_key.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Remove a solid chroma-key background from an image. + +This helper supports the imagegen skill's built-in-first transparent workflow: +generate an image on a flat key color, then convert that key color to alpha. +""" + +from __future__ import annotations + +import argparse +from io import BytesIO +from pathlib import Path +import re +from statistics import median +import sys +from typing import Tuple + + +Color = Tuple[int, int, int] +KEY_DOMINANCE_THRESHOLD = 16.0 +ALPHA_NOISE_FLOOR = 8 + + +def _die(message: str, code: int = 1) -> None: + print(f"Error: {message}", file=sys.stderr) + raise SystemExit(code) + + +def _dependency_hint(package: str) -> str: + return ( + "Activate the repo-selected environment first, then install it with " + f"`uv pip install {package}`. If this repo uses a local virtualenv, start with " + "`source .venv/bin/activate`; otherwise use this repo's configured shared fallback " + "environment." + ) + + +def _load_pillow(): + try: + from PIL import Image, ImageFilter + except ImportError: + _die(f"Pillow is required for chroma-key removal. {_dependency_hint('pillow')}") + return Image, ImageFilter + + +def _parse_key_color(raw: str) -> Color: + value = raw.strip() + match = re.fullmatch(r"#?([0-9a-fA-F]{6})", value) + if not match: + _die("key color must be a hex RGB value like #00ff00.") + hex_value = match.group(1) + return ( + int(hex_value[0:2], 16), + int(hex_value[2:4], 16), + int(hex_value[4:6], 16), + ) + + +def _validate_args(args: argparse.Namespace) -> None: + if args.tolerance < 0 or args.tolerance > 255: + _die("--tolerance must be between 0 and 255.") + if args.transparent_threshold < 0 or args.transparent_threshold > 255: + _die("--transparent-threshold must be between 0 and 255.") + if args.opaque_threshold < 0 or args.opaque_threshold > 255: + _die("--opaque-threshold must be between 0 and 255.") + if args.soft_matte and args.transparent_threshold >= args.opaque_threshold: + _die("--transparent-threshold must be lower than --opaque-threshold.") + if args.edge_feather < 0 or args.edge_feather > 64: + _die("--edge-feather must be between 0 and 64.") + if args.edge_contract < 0 or args.edge_contract > 16: + _die("--edge-contract must be between 0 and 16.") + + src = Path(args.input) + if not src.exists(): + _die(f"Input image not found: {src}") + + out = Path(args.out) + if out.exists() and not args.force: + _die(f"Output already exists: {out} (use --force to overwrite)") + + if out.suffix.lower() not in {".png", ".webp"}: + _die("--out must end in .png or .webp so the alpha channel is preserved.") + + +def _channel_distance(a: Color, b: Color) -> int: + return max(abs(a[0] - b[0]), abs(a[1] - b[1]), abs(a[2] - b[2])) + + +def _clamp_channel(value: float) -> int: + return max(0, min(255, int(round(value)))) + + +def _smoothstep(value: float) -> float: + value = max(0.0, min(1.0, value)) + return value * value * (3.0 - 2.0 * value) + + +def _soft_alpha(distance: int, transparent_threshold: float, opaque_threshold: float) -> int: + if distance <= transparent_threshold: + return 0 + if distance >= opaque_threshold: + return 255 + ratio = (float(distance) - transparent_threshold) / ( + opaque_threshold - transparent_threshold + ) + return _clamp_channel(255.0 * _smoothstep(ratio)) + + +def _dominance_alpha(rgb: Color, key: Color) -> int: + spill_channels = _spill_channels(key) + if not spill_channels: + return 255 + + channels = [float(value) for value in rgb] + non_spill = [idx for idx in range(3) if idx not in spill_channels] + key_strength = ( + min(channels[idx] for idx in spill_channels) + if len(spill_channels) > 1 + else channels[spill_channels[0]] + ) + non_key_strength = max((channels[idx] for idx in non_spill), default=0.0) + dominance = key_strength - non_key_strength + if dominance <= 0: + return 255 + + denominator = max(1.0, float(max(key)) - non_key_strength) + alpha = 1.0 - min(1.0, dominance / denominator) + return _clamp_channel(alpha * 255.0) + + +def _spill_channels(key: Color) -> list[int]: + key_max = max(key) + if key_max < 128: + return [] + return [idx for idx, value in enumerate(key) if value >= key_max - 16 and value >= 128] + + +def _key_channel_dominance(rgb: Color, key: Color) -> float: + spill_channels = _spill_channels(key) + if not spill_channels: + return 0.0 + + channels = [float(value) for value in rgb] + non_spill = [idx for idx in range(3) if idx not in spill_channels] + key_strength = ( + min(channels[idx] for idx in spill_channels) + if len(spill_channels) > 1 + else channels[spill_channels[0]] + ) + non_key_strength = max((channels[idx] for idx in non_spill), default=0.0) + return key_strength - non_key_strength + + +def _looks_key_colored(rgb: Color, key: Color, distance: int) -> bool: + if distance <= 32: + return True + + spill_channels = _spill_channels(key) + if not spill_channels: + return True + + return _key_channel_dominance(rgb, key) >= KEY_DOMINANCE_THRESHOLD + + +def _cleanup_spill(rgb: Color, key: Color, alpha: int = 255) -> Color: + if alpha >= 252: + return rgb + + spill_channels = _spill_channels(key) + if not spill_channels: + return rgb + + channels = [float(value) for value in rgb] + non_spill = [idx for idx in range(3) if idx not in spill_channels] + if non_spill: + anchor = max(channels[idx] for idx in non_spill) + cap = max(0.0, anchor - 1.0) + for idx in spill_channels: + if channels[idx] > cap: + channels[idx] = cap + + return ( + _clamp_channel(channels[0]), + _clamp_channel(channels[1]), + _clamp_channel(channels[2]), + ) + + +def _apply_alpha_to_image( + image, + *, + key: Color, + tolerance: int, + spill_cleanup: bool, + soft_matte: bool, + transparent_threshold: float, + opaque_threshold: float, +) -> int: + pixels = image.load() + width, height = image.size + transparent = 0 + + for y in range(height): + for x in range(width): + red, green, blue, alpha = pixels[x, y] + rgb = (red, green, blue) + distance = _channel_distance(rgb, key) + key_like = _looks_key_colored(rgb, key, distance) + output_alpha = ( + min( + _soft_alpha(distance, transparent_threshold, opaque_threshold), + _dominance_alpha(rgb, key), + ) + if soft_matte and key_like + else (0 if distance <= tolerance else 255) + ) + output_alpha = int(round(output_alpha * (alpha / 255.0))) + if 0 < output_alpha <= ALPHA_NOISE_FLOOR: + output_alpha = 0 + + if output_alpha == 0: + pixels[x, y] = (0, 0, 0, 0) + transparent += 1 + continue + + if spill_cleanup and key_like: + red, green, blue = _cleanup_spill(rgb, key, output_alpha) + pixels[x, y] = (red, green, blue, output_alpha) + + return transparent + + +def _contract_alpha(image, pixels: int): + if pixels == 0: + return image + + _, ImageFilter = _load_pillow() + alpha = image.getchannel("A") + for _ in range(pixels): + alpha = alpha.filter(ImageFilter.MinFilter(3)) + image.putalpha(alpha) + return image + + +def _apply_edge_feather(image, radius: float): + if radius == 0: + return image + + _, ImageFilter = _load_pillow() + alpha = image.getchannel("A") + alpha = alpha.filter(ImageFilter.GaussianBlur(radius=radius)) + image.putalpha(alpha) + return image + + +def _encode_image(image, output_format: str) -> bytes: + out = BytesIO() + image.save(out, format=output_format.upper()) + return out.getvalue() + + +def _alpha_counts(image) -> tuple[int, int, int]: + pixels = image.load() + width, height = image.size + total = 0 + transparent = 0 + partial = 0 + + for y in range(height): + for x in range(width): + alpha = pixels[x, y][3] + total += 1 + if alpha == 0: + transparent += 1 + elif alpha < 255: + partial += 1 + + return total, transparent, partial + + +def _sample_border_key(image, mode: str) -> Color: + width, height = image.size + pixels = image.load() + samples: list[Color] = [] + + if mode == "corners": + patch = max(1, min(width, height, 12)) + boxes = [ + (0, 0, patch, patch), + (width - patch, 0, width, patch), + (0, height - patch, patch, height), + (width - patch, height - patch, width, height), + ] + for left, top, right, bottom in boxes: + for y in range(top, bottom): + for x in range(left, right): + red, green, blue = pixels[x, y][:3] + samples.append((red, green, blue)) + else: + band = max(1, min(width, height, 6)) + step = max(1, min(width, height) // 256) + for x in range(0, width, step): + for y in range(band): + red, green, blue = pixels[x, y][:3] + samples.append((red, green, blue)) + red, green, blue = pixels[x, height - 1 - y][:3] + samples.append((red, green, blue)) + for y in range(0, height, step): + for x in range(band): + red, green, blue = pixels[x, y][:3] + samples.append((red, green, blue)) + red, green, blue = pixels[width - 1 - x, y][:3] + samples.append((red, green, blue)) + + if not samples: + _die("Could not sample background key color from image border.") + + return ( + int(round(median(sample[0] for sample in samples))), + int(round(median(sample[1] for sample in samples))), + int(round(median(sample[2] for sample in samples))), + ) + + +def _remove_chroma_key(args: argparse.Namespace) -> None: + Image, _ = _load_pillow() + src = Path(args.input) + out = Path(args.out) + + with Image.open(src) as image: + rgba = image.convert("RGBA") + key = ( + _sample_border_key(rgba, args.auto_key) + if args.auto_key != "none" + else _parse_key_color(args.key_color) + ) + + transparent = _apply_alpha_to_image( + rgba, + key=key, + tolerance=args.tolerance, + spill_cleanup=args.spill_cleanup, + soft_matte=args.soft_matte, + transparent_threshold=args.transparent_threshold, + opaque_threshold=args.opaque_threshold, + ) + rgba = _contract_alpha(rgba, args.edge_contract) + rgba = _apply_edge_feather(rgba, args.edge_feather) + + total, transparent_after, partial_after = _alpha_counts(rgba) + + out.parent.mkdir(parents=True, exist_ok=True) + output_format = "PNG" if out.suffix.lower() == ".png" else "WEBP" + out.write_bytes(_encode_image(rgba, output_format)) + + print(f"Wrote {out}") + print(f"Key color: #{key[0]:02x}{key[1]:02x}{key[2]:02x}") + print(f"Transparent pixels: {transparent_after}/{total}") + print(f"Partially transparent pixels: {partial_after}/{total}") + if transparent == 0: + print("Warning: no pixels matched the key color before feathering.", file=sys.stderr) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Remove a solid chroma-key background and write an image with alpha." + ) + parser.add_argument("--input", required=True, help="Input image path.") + parser.add_argument("--out", required=True, help="Output .png or .webp path.") + parser.add_argument( + "--key-color", + default="#00ff00", + help="Hex RGB key color to remove, for example #00ff00.", + ) + parser.add_argument( + "--tolerance", + type=int, + default=12, + help="Hard-key per-channel tolerance for matching the key color, 0-255.", + ) + parser.add_argument( + "--auto-key", + choices=["none", "corners", "border"], + default="none", + help="Sample the key color from image corners or border instead of --key-color.", + ) + parser.add_argument( + "--soft-matte", + action="store_true", + help="Use a smooth alpha ramp between transparent and opaque thresholds.", + ) + parser.add_argument( + "--transparent-threshold", + type=float, + default=12.0, + help="Soft-matte distance at or below which pixels become fully transparent.", + ) + parser.add_argument( + "--opaque-threshold", + type=float, + default=96.0, + help="Soft-matte distance at or above which pixels become fully opaque.", + ) + parser.add_argument( + "--edge-feather", + type=float, + default=0.0, + help="Optional alpha blur radius for softened edges, 0-64.", + ) + parser.add_argument( + "--edge-contract", + type=int, + default=0, + help="Shrink the visible alpha matte by this many pixels before feathering.", + ) + parser.add_argument( + "--spill-cleanup", + dest="spill_cleanup", + action="store_true", + help="Reduce obvious key-color spill on opaque pixels.", + ) + parser.add_argument( + "--despill", + dest="spill_cleanup", + action="store_true", + help="Alias for --spill-cleanup; decontaminate key-color edge spill.", + ) + parser.add_argument("--force", action="store_true", help="Overwrite an existing output file.") + return parser + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + _validate_args(args) + _remove_chroma_key(args) + + +if __name__ == "__main__": + main() From 0127cef5db1e220c9256606ef5e521ba4320caef Mon Sep 17 00:00:00 2001 From: Steve Coffey Date: Wed, 22 Apr 2026 08:14:48 -0700 Subject: [PATCH 10/11] Stage publishable Python runtime wheels (#18865) This is PR 2 of the Python SDK PyPI publishing split. [PR 1](https://github.com/openai/codex/pull/18862) refreshed the generated SDK bindings; this PR makes the runtime package itself publishable, and PR 3 will wire the SDK package/version pinning to this runtime package. ## Summary - Rename the runtime distribution to `openai-codex-cli-bin` while keeping the import package as `codex_cli_bin`. - Make the runtime package wheel-only and build `py3-none-` wheels instead of interpreter-specific wheels. - Add `stage-runtime --codex-version` and `--platform-tag` so release staging can produce the platform wheel matrix from Codex release tags. - Add focused artifact workflow tests for version normalization, platform tag injection, and runtime wheel metadata. ## Why Rename There is already an unofficial PyPI package, [`codex-bin`](https://pypi.org/project/codex-bin/), distributing OpenAI Codex binaries. Publishing the official SDK runtime dependency as `openai-codex-cli-bin` makes the ownership clear, avoids confusing the SDK-pinned runtime wheel with that unowned wrapper, and keeps the import package unchanged as `codex_cli_bin`. ## Tests - `uv run --extra dev pytest tests/test_artifact_workflow_and_binaries.py` -> 21 passed - `uv run --extra dev python scripts/update_sdk_artifacts.py stage-runtime /tmp/codex-python-pr2-rebased/runtime-stage /tmp/codex-python-pr2-rebased/codex --codex-version rust-v0.116.0-alpha.1 --platform-tag macosx_11_0_arm64` - `uv run --with build --extra dev python -m build --wheel /tmp/codex-python-pr2-rebased/runtime-stage` - `uv run --with twine --extra dev twine check /tmp/codex-python-pr2-rebased/runtime-stage/dist/openai_codex_cli_bin-0.116.0a1-py3-none-macosx_11_0_arm64.whl` ## Note - Full `uv run --extra dev pytest` currently fails because regenerating from schemas already on `main` adds new DeviceKey Python types. I left that generated catch-up out of this runtime-only PR. --- sdk/python-runtime/README.md | 4 +- sdk/python-runtime/hatch_build.py | 19 ++- sdk/python-runtime/pyproject.toml | 2 +- .../src/codex_cli_bin/__init__.py | 2 +- sdk/python/README.md | 14 +- sdk/python/_runtime_setup.py | 4 +- sdk/python/docs/faq.md | 6 +- sdk/python/docs/getting-started.md | 2 +- sdk/python/examples/README.md | 8 +- sdk/python/scripts/update_sdk_artifacts.py | 122 ++++++++++++++++-- sdk/python/src/codex_app_server/client.py | 2 +- .../test_artifact_workflow_and_binaries.py | 97 +++++++++++--- 12 files changed, 228 insertions(+), 54 deletions(-) diff --git a/sdk/python-runtime/README.md b/sdk/python-runtime/README.md index 0e7b26d5fc..22c59ef156 100644 --- a/sdk/python-runtime/README.md +++ b/sdk/python-runtime/README.md @@ -5,5 +5,5 @@ Platform-specific runtime package consumed by the published `codex-app-server-sd This package is staged during release so the SDK can pin an exact Codex CLI version without checking platform binaries into the repo. -`codex-cli-bin` is intentionally wheel-only. Do not build or publish an sdist -for this package. +`openai-codex-cli-bin` is intentionally wheel-only. Do not build or publish an +sdist for this package. diff --git a/sdk/python-runtime/hatch_build.py b/sdk/python-runtime/hatch_build.py index 319e6973fb..d49e3e106a 100644 --- a/sdk/python-runtime/hatch_build.py +++ b/sdk/python-runtime/hatch_build.py @@ -1,15 +1,30 @@ from __future__ import annotations +import os + from hatchling.builders.hooks.plugin.interface import BuildHookInterface +def _platform_tag() -> str: + from packaging.tags import sys_tags + + return next(iter(sys_tags())).platform + + class RuntimeBuildHook(BuildHookInterface): def initialize(self, version: str, build_data: dict[str, object]) -> None: del version if self.target_name == "sdist": raise RuntimeError( - "codex-cli-bin is wheel-only; build and publish platform wheels only." + "openai-codex-cli-bin is wheel-only; build and publish platform wheels only." ) + platform_tag = self.config.get("platform-tag") or os.environ.get( + "CODEX_CLI_BIN_PLATFORM_TAG" + ) + if not isinstance(platform_tag, str) or not platform_tag: + platform_tag = _platform_tag() + build_data["pure_python"] = False - build_data["infer_tag"] = True + build_data["infer_tag"] = False + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/sdk/python-runtime/pyproject.toml b/sdk/python-runtime/pyproject.toml index 761dccbb9f..281cb7d1a4 100644 --- a/sdk/python-runtime/pyproject.toml +++ b/sdk/python-runtime/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.24.0"] build-backend = "hatchling.build" [project] -name = "codex-cli-bin" +name = "openai-codex-cli-bin" version = "0.0.0-dev" description = "Pinned Codex CLI runtime for the Python SDK" readme = "README.md" diff --git a/sdk/python-runtime/src/codex_cli_bin/__init__.py b/sdk/python-runtime/src/codex_cli_bin/__init__.py index 8059d39211..dbd9a6f660 100644 --- a/sdk/python-runtime/src/codex_cli_bin/__init__.py +++ b/sdk/python-runtime/src/codex_cli_bin/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import os from pathlib import Path -PACKAGE_NAME = "codex-cli-bin" +PACKAGE_NAME = "openai-codex-cli-bin" def bundled_codex_path() -> Path: diff --git a/sdk/python/README.md b/sdk/python/README.md index 97068afe31..1331ebfe26 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -11,7 +11,7 @@ cd sdk/python python -m pip install -e . ``` -Published SDK builds pin an exact `codex-cli-bin` runtime dependency. For local +Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency. For local repo development, either pass `AppServerConfig(codex_bin=...)` to point at a local build explicitly, or use the repo examples/notebook bootstrap which installs the pinned runtime package automatically. @@ -53,7 +53,7 @@ python examples/01_quickstart_constructor/async.py The repo no longer checks `codex` binaries into `sdk/python`. -Published SDK builds are pinned to an exact `codex-cli-bin` package version, +Published SDK builds are pinned to an exact `openai-codex-cli-bin` package version, and that runtime package carries the platform-specific binary for the target wheel. @@ -73,7 +73,7 @@ python scripts/update_sdk_artifacts.py \ --runtime-version 1.2.3 python scripts/update_sdk_artifacts.py \ stage-runtime \ - /tmp/codex-python-release/codex-cli-bin \ + /tmp/codex-python-release/openai-codex-cli-bin \ /path/to/codex \ --runtime-version 1.2.3 ``` @@ -81,14 +81,14 @@ python scripts/update_sdk_artifacts.py \ This supports the CI release flow: - run `generate-types` before packaging -- stage `codex-app-server-sdk` once with an exact `codex-cli-bin==...` dependency -- stage `codex-cli-bin` on each supported platform runner with the same pinned runtime version -- build and publish `codex-cli-bin` as platform wheels only; do not publish an sdist +- stage `codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency +- stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version +- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist ## Compatibility and versioning - Package: `codex-app-server-sdk` -- Runtime package: `codex-cli-bin` +- Runtime package: `openai-codex-cli-bin` - Current SDK version in this repo: `0.2.0` - Python: `>=3.10` - Target protocol: Codex `app-server` JSON-RPC v2 diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py index 5eb3999f4c..6c4cf457ad 100644 --- a/sdk/python/_runtime_setup.py +++ b/sdk/python/_runtime_setup.py @@ -15,7 +15,7 @@ import urllib.request import zipfile from pathlib import Path -PACKAGE_NAME = "codex-cli-bin" +PACKAGE_NAME = "openai-codex-cli-bin" PINNED_RUNTIME_VERSION = "0.116.0-alpha.1" REPO_SLUG = "openai/codex" @@ -105,7 +105,7 @@ def _installed_runtime_version(python_executable: str | Path) -> str | None: "try:\n" " from codex_cli_bin import bundled_codex_path\n" " bundled_codex_path()\n" - " print(json.dumps({'version': importlib.metadata.version('codex-cli-bin')}))\n" + f" print(json.dumps({{'version': importlib.metadata.version({PACKAGE_NAME!r})}}))\n" "except Exception:\n" " sys.exit(1)\n" ) diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index b2c9cf3b1f..af688a3a19 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -54,13 +54,13 @@ This avoids duplicate ways to do the same operation and keeps behavior explicit. Common causes: -- published runtime package (`codex-cli-bin`) is not installed +- published runtime package (`openai-codex-cli-bin`) is not installed - local `codex_bin` override points to a missing file - local auth/session is missing - incompatible/old app-server Maintainers stage releases by building the SDK once and the runtime once per -platform with the same pinned runtime version. Publish `codex-cli-bin` as +platform with the same pinned runtime version. Publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist: ```bash @@ -72,7 +72,7 @@ python scripts/update_sdk_artifacts.py \ --runtime-version 1.2.3 python scripts/update_sdk_artifacts.py \ stage-runtime \ - /tmp/codex-python-release/codex-cli-bin \ + /tmp/codex-python-release/openai-codex-cli-bin \ /path/to/codex \ --runtime-version 1.2.3 ``` diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 76034d72ee..70a193a3d5 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -16,7 +16,7 @@ python -m pip install -e . Requirements: - Python `>=3.10` -- installed `codex-cli-bin` runtime package, or an explicit `codex_bin` override +- installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override - local Codex auth/session configured ## 2) Run your first turn (sync) diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 5edf2badbd..ffdc86162b 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -23,11 +23,11 @@ python -m pip install -e . When running examples from this repo checkout, the SDK source uses the local tree and does not bundle a runtime binary. The helper in `examples/_bootstrap.py` -uses the installed `codex-cli-bin` runtime package. +uses the installed `openai-codex-cli-bin` runtime package. -If the pinned `codex-cli-bin` runtime is not already installed, the bootstrap +If the pinned `openai-codex-cli-bin` runtime is not already installed, the bootstrap will download the matching GitHub release artifact, stage a temporary local -`codex-cli-bin` package, install it into your active interpreter, and clean up +`openai-codex-cli-bin` package, install it into your active interpreter, and clean up the temporary files afterward. Current pinned runtime version: `0.116.0-alpha.1` @@ -43,7 +43,7 @@ python examples//async.py The examples bootstrap local imports from `sdk/python/src` automatically, so no SDK wheel install is required. You only need the Python dependencies for your -active interpreter and an installed `codex-cli-bin` runtime package (either +active interpreter and an installed `openai-codex-cli-bin` runtime package (either already present or automatically provisioned by the bootstrap). ## Recommended first run diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 6685fd0999..42c1ec091f 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -17,6 +17,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Sequence, get_args, get_origin +RUNTIME_DISTRIBUTION_NAME = "openai-codex-cli-bin" + def repo_root() -> Path: return Path(__file__).resolve().parents[3] @@ -76,6 +78,24 @@ def current_sdk_version() -> str: return match.group(1) +def normalize_codex_version(version: str) -> str: + normalized = version.strip() + if normalized.startswith("rust-v"): + normalized = normalized.removeprefix("rust-v") + elif normalized.startswith("v"): + normalized = normalized.removeprefix("v") + + normalized = re.sub(r"-alpha\.?([0-9]+)$", r"a\1", normalized) + normalized = re.sub(r"-beta\.?([0-9]+)$", r"b\1", normalized) + normalized = re.sub(r"-rc\.?([0-9]+)$", r"rc\1", normalized) + + if not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*(?:(?:a|b|rc)[0-9]+)?", normalized): + raise RuntimeError( + f"Could not normalize Codex version {version!r} to a PEP 440 version" + ) + return normalized + + def _copy_package_tree(src: Path, dst: Path) -> None: if dst.exists(): if dst.is_dir(): @@ -110,6 +130,46 @@ def _rewrite_project_version(pyproject_text: str, version: str) -> str: return updated +def _rewrite_runtime_platform_tag(pyproject_text: str, platform_tag: str) -> str: + section = "[tool.hatch.build.targets.wheel.hooks.custom]" + section_index = pyproject_text.find(section) + if section_index == -1: + raise RuntimeError("Could not find runtime wheel custom hook config") + + next_section_index = pyproject_text.find("\n[", section_index + len(section)) + if next_section_index == -1: + section_text = pyproject_text[section_index:] + tail = "" + else: + section_text = pyproject_text[section_index:next_section_index] + tail = pyproject_text[next_section_index:] + + updated_section, count = re.subn( + r'^platform-tag = "[^"]*"$', + f'platform-tag = "{platform_tag}"', + section_text, + count=1, + flags=re.MULTILINE, + ) + if count == 0: + updated_section = section_text.rstrip() + f'\nplatform-tag = "{platform_tag}"\n' + + return pyproject_text[:section_index] + updated_section + tail + + +def _rewrite_project_name(pyproject_text: str, name: str) -> str: + updated, count = re.subn( + r'^name = "[^"]+"$', + f'name = "{name}"', + pyproject_text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError("Could not rewrite project name in pyproject.toml") + return updated + + def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) -> str: match = re.search(r"^dependencies = \[(.*?)\]$", pyproject_text, flags=re.MULTILINE) if match is None: @@ -119,7 +179,7 @@ def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) - raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()] raw_items = [item for item in raw_items if "codex-cli-bin" not in item] - raw_items.append(f'"codex-cli-bin=={runtime_version}"') + raw_items.append(f'"{RUNTIME_DISTRIBUTION_NAME}=={runtime_version}"') replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]" return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :] @@ -141,14 +201,21 @@ def stage_python_sdk_package( def stage_python_runtime_package( - staging_dir: Path, runtime_version: str, binary_path: Path + staging_dir: Path, + codex_version: str, + binary_path: Path, + platform_tag: str | None = None, ) -> Path: + package_version = normalize_codex_version(codex_version) _copy_package_tree(python_runtime_root(), staging_dir) pyproject_path = staging_dir / "pyproject.toml" - pyproject_path.write_text( - _rewrite_project_version(pyproject_path.read_text(), runtime_version) - ) + pyproject_text = pyproject_path.read_text() + pyproject_text = _rewrite_project_name(pyproject_text, RUNTIME_DISTRIBUTION_NAME) + pyproject_text = _rewrite_project_version(pyproject_text, package_version) + if platform_tag is not None: + pyproject_text = _rewrite_runtime_platform_tag(pyproject_text, platform_tag) + pyproject_path.write_text(pyproject_text) out_bin = staged_runtime_bin_path(staging_dir) out_bin.parent.mkdir(parents=True, exist_ok=True) @@ -559,7 +626,7 @@ class PublicFieldSpec: class CliOps: generate_types: Callable[[], None] stage_python_sdk_package: Callable[[Path, str, str], Path] - stage_python_runtime_package: Callable[[Path, str, Path], Path] + stage_python_runtime_package: Callable[[Path, str, Path, str | None], Path] current_sdk_version: Callable[[], str] @@ -928,7 +995,7 @@ def build_parser() -> argparse.ArgumentParser: stage_sdk_parser.add_argument( "--runtime-version", required=True, - help="Pinned codex-cli-bin version for the staged SDK package", + help="Pinned openai-codex-cli-bin version for the staged SDK package", ) stage_sdk_parser.add_argument( "--sdk-version", @@ -949,10 +1016,23 @@ def build_parser() -> argparse.ArgumentParser: type=Path, help="Path to the codex binary to package for this platform", ) + stage_runtime_parser.add_argument( + "--codex-version", + help=( + "Codex release version to write into the staged runtime package. " + "Accepts PEP 440 versions or release tags such as rust-v0.116.0-alpha.1." + ), + ) stage_runtime_parser.add_argument( "--runtime-version", - required=True, - help="Version to write into the staged runtime package", + help=argparse.SUPPRESS, + ) + stage_runtime_parser.add_argument( + "--platform-tag", + help=( + "Optional wheel platform tag override, for example " + "macosx_11_0_arm64 or musllinux_1_1_x86_64." + ), ) return parser @@ -970,6 +1050,26 @@ def default_cli_ops() -> CliOps: ) +def _resolve_runtime_version(args: argparse.Namespace) -> str: + versions = [ + value + for value in ( + getattr(args, "codex_version", None), + getattr(args, "runtime_version", None), + ) + if value is not None + ] + if not versions: + raise RuntimeError("Pass --codex-version to stage the Python runtime package") + + normalized_versions = [normalize_codex_version(version) for version in versions] + if len(set(normalized_versions)) != 1: + raise RuntimeError( + "Runtime package versions must match; pass one --codex-version" + ) + return normalized_versions[0] + + def run_command(args: argparse.Namespace, ops: CliOps) -> None: if args.command == "generate-types": ops.generate_types() @@ -981,10 +1081,12 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None: args.runtime_version, ) elif args.command == "stage-runtime": + runtime_version = _resolve_runtime_version(args) ops.stage_python_runtime_package( args.staging_dir, - args.runtime_version, + runtime_version, args.runtime_binary.resolve(), + args.platform_tag, ) diff --git a/sdk/python/src/codex_app_server/client.py b/sdk/python/src/codex_app_server/client.py index 146d5186e1..db7cf77cd5 100644 --- a/sdk/python/src/codex_app_server/client.py +++ b/sdk/python/src/codex_app_server/client.py @@ -47,7 +47,7 @@ from .retry import retry_on_overload ModelT = TypeVar("ModelT", bound=BaseModel) ApprovalHandler = Callable[[str, JsonObject | None], JsonObject] -RUNTIME_PKG_NAME = "codex-cli-bin" +RUNTIME_PKG_NAME = "openai-codex-cli-bin" def _params_dict( diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index b19dc745a3..03252154e7 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -168,6 +168,19 @@ def test_examples_readme_matches_pinned_runtime_version() -> None: ) +def test_runtime_distribution_name_is_consistent() -> None: + script = _load_update_script_module() + runtime_setup = _load_runtime_setup_module() + from codex_app_server import client as client_module + + assert script.RUNTIME_DISTRIBUTION_NAME == "openai-codex-cli-bin" + assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin" + assert client_module.RUNTIME_PKG_NAME == "openai-codex-cli-bin" + assert "importlib.metadata.version('codex-cli-bin')" not in ( + ROOT / "_runtime_setup.py" + ).read_text() + + def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None: runtime_setup = _load_runtime_setup_module() authorizations: list[str | None] = [] @@ -222,19 +235,24 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> ), None, ) - build_data_assignments = { - node.targets[0].slice.value: node.value.value - for node in initialize_fn.body - if isinstance(node, ast.Assign) - and len(node.targets) == 1 - and isinstance(node.targets[0], ast.Subscript) - and isinstance(node.targets[0].value, ast.Name) - and node.targets[0].value.id == "build_data" - and isinstance(node.targets[0].slice, ast.Constant) - and isinstance(node.targets[0].slice.value, str) - and isinstance(node.value, ast.Constant) - } + build_data_assignments = {} + for node in initialize_fn.body: + if ( + not isinstance(node, ast.Assign) + or len(node.targets) != 1 + or not isinstance(node.targets[0], ast.Subscript) + or not isinstance(node.targets[0].value, ast.Name) + or node.targets[0].value.id != "build_data" + or not isinstance(node.targets[0].slice, ast.Constant) + or not isinstance(node.targets[0].slice.value, str) + ): + continue + if isinstance(node.value, ast.Constant): + build_data_assignments[node.targets[0].slice.value] = node.value.value + elif isinstance(node.value, ast.JoinedStr): + build_data_assignments[node.targets[0].slice.value] = "joined-string" + assert pyproject["project"]["name"] == "openai-codex-cli-bin" assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == { "packages": ["src/codex_cli_bin"], "include": ["src/codex_cli_bin/bin/**"], @@ -244,7 +262,11 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> "hooks": {"custom": {}}, } assert sdist_guard is not None - assert build_data_assignments == {"pure_python": False, "infer_tag": True} + assert build_data_assignments == { + "pure_python": False, + "infer_tag": False, + "tag": "joined-string", + } def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> None: @@ -260,9 +282,19 @@ def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> assert staged == tmp_path / "runtime-stage" assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" + assert 'name = "openai-codex-cli-bin"' in (staged / "pyproject.toml").read_text() assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text() +def test_normalize_codex_version_accepts_release_tags_and_pep440_versions() -> None: + script = _load_update_script_module() + + assert script.normalize_codex_version("rust-v0.116.0-alpha.1") == "0.116.0a1" + assert script.normalize_codex_version("v0.116.0-beta.2") == "0.116.0b2" + assert script.normalize_codex_version("0.116.0rc3") == "0.116.0rc3" + assert script.normalize_codex_version("0.116.0") == "0.116.0" + + def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None: script = _load_update_script_module() staging_dir = tmp_path / "runtime-stage" @@ -284,13 +316,30 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" +def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "0.116.0a1", + fake_binary, + platform_tag="musllinux_1_1_x86_64", + ) + + pyproject = (staged / "pyproject.toml").read_text() + assert 'platform-tag = "musllinux_1_1_x86_64"' in pyproject + + def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None: script = _load_update_script_module() staged = script.stage_python_sdk_package(tmp_path / "sdk-stage", "0.2.1", "1.2.3") pyproject = (staged / "pyproject.toml").read_text() assert 'version = "0.2.1"' in pyproject - assert '"codex-cli-bin==1.2.3"' in pyproject + assert '"openai-codex-cli-bin==1.2.3"' in pyproject + assert '"codex-cli-bin==1.2.3"' not in pyproject assert not any((staged / "src" / "codex_app_server").glob("bin/**")) @@ -329,7 +378,10 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None: return tmp_path / "sdk-stage" def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, + _runtime_version: str, + _runtime_binary: Path, + _platform_tag: str | None, ) -> Path: raise AssertionError("runtime staging should not run for stage-sdk") @@ -358,8 +410,10 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> "stage-runtime", str(tmp_path / "runtime-stage"), str(fake_binary), - "--runtime-version", - "1.2.3", + "--codex-version", + "rust-v0.116.0-alpha.1", + "--platform-tag", + "musllinux_1_1_x86_64", ] ) @@ -372,9 +426,12 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> raise AssertionError("sdk staging should not run for stage-runtime") def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, + codex_version: str, + _runtime_binary: Path, + platform_tag: str | None, ) -> Path: - calls.append("stage_runtime") + calls.append(f"stage_runtime:{codex_version}:{platform_tag}") return tmp_path / "runtime-stage" def fake_current_sdk_version() -> str: @@ -389,7 +446,7 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> script.run_command(args, ops) - assert calls == ["stage_runtime"] + assert calls == ["stage_runtime:0.116.0a1:musllinux_1_1_x86_64"] def test_default_runtime_is_resolved_from_installed_runtime_package( From d7716e0f26eda0a074ba09e37c9fb12a9a756165 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 09:21:29 -0700 Subject: [PATCH 11/11] arg0: keep dispatch aliases alive during async main --- codex-rs/arg0/src/lib.rs | 59 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 38f88452af..a5bcf73b52 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -199,7 +199,11 @@ where .and_then(|path_entry| path_entry.paths().main_execve_wrapper_exe.clone()), }; - main_fn(paths).await + let result = main_fn(paths).await; + // Keep the arg0 tempdir guard alive until the async entry point finishes; + // runtime paths above can point at aliases inside that directory. + drop(path_entry_guard); + result }) } @@ -440,8 +444,10 @@ mod tests { use super::Arg0DispatchPaths; use super::Arg0PathEntryGuard; use super::LOCK_FILENAME; + use super::arg0_dispatch_or_else; use super::janitor_cleanup; use super::linux_sandbox_exe_path; + use anyhow::ensure; use std::fs; use std::fs::File; use std::path::Path; @@ -480,6 +486,57 @@ mod tests { Ok(()) } + #[cfg(unix)] + #[test] + fn arg0_dispatch_or_else_keeps_aliases_alive_until_main_returns() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let previous_codex_home = std::env::var_os("CODEX_HOME"); + let previous_path = std::env::var_os("PATH"); + unsafe { + std::env::set_var("CODEX_HOME", codex_home.path()); + } + + let result = arg0_dispatch_or_else(|paths| async move { + let alias_path = paths + .codex_linux_sandbox_exe + .or(paths.main_execve_wrapper_exe) + .expect("unix dispatch should create at least one alias path"); + ensure!( + alias_path.exists(), + "alias path disappeared before main future was polled: {}", + alias_path.display() + ); + + tokio::task::yield_now().await; + + ensure!( + alias_path.exists(), + "alias path disappeared while main future was running: {}", + alias_path.display() + ); + Ok(()) + }); + + match previous_codex_home { + Some(value) => unsafe { + std::env::set_var("CODEX_HOME", value); + }, + None => unsafe { + std::env::remove_var("CODEX_HOME"); + }, + } + match previous_path { + Some(value) => unsafe { + std::env::set_var("PATH", value); + }, + None => unsafe { + std::env::remove_var("PATH"); + }, + } + + result + } + #[test] fn janitor_skips_dirs_without_lock_file() -> std::io::Result<()> { let root = tempfile::tempdir()?;