Codex Handbook
core/src/client_tests.rs 632 lines
use super::AuthRequestTelemetryContext;use super::ModelClient;use super::PendingUnauthorizedRetry;use super::UnauthorizedRecoveryExecution;use super::X_CODEX_INSTALLATION_ID_HEADER;use super::X_CODEX_PARENT_THREAD_ID_HEADER;use super::X_CODEX_TURN_METADATA_HEADER;use super::X_CODEX_WINDOW_ID_HEADER;use super::X_OPENAI_SUBAGENT_HEADER;use crate::AttestationContext;use crate::AttestationProvider;use crate::GenerateAttestationFuture;use crate::responses_metadata::CodexResponsesMetadata;use crate::test_support::TestCodexResponsesRequestKind;use crate::test_support::responses_metadata as test_responses_metadata;use codex_api::ApiError;use codex_api::ResponseEvent;use codex_app_server_protocol::AuthMode;use codex_login::AuthManager;use codex_login::CodexAuth;use codex_model_provider::BearerAuthProvider;use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;use codex_model_provider_info::ModelProviderInfo;use codex_model_provider_info::WireApi;use codex_model_provider_info::create_oss_provider_with_base_url;use codex_otel::SessionTelemetry;use codex_protocol::ThreadId;use codex_protocol::models::ContentItem;use codex_protocol::models::ResponseItem;use codex_protocol::openai_models::ModelInfo;use codex_protocol::protocol::InternalSessionSource;use codex_protocol::protocol::SessionSource;use codex_protocol::protocol::SubAgentSource;use codex_rollout_trace::ExecutionStatus;use codex_rollout_trace::InferenceTraceAttempt;use codex_rollout_trace::InferenceTraceContext;use codex_rollout_trace::RawTraceEventPayload;use codex_rollout_trace::RolloutTrace;use codex_rollout_trace::TraceWriter;use codex_rollout_trace::replay_bundle;use futures::StreamExt;use pretty_assertions::assert_eq;use serde_json::json;use std::collections::BTreeMap;use std::collections::VecDeque;use std::pin::Pin;use std::sync::Arc;use std::sync::Mutex;use std::sync::atomic::AtomicUsize;use std::sync::atomic::Ordering;use std::task::Context;use std::task::Poll;use std::time::Duration;use tempfile::TempDir;use tokio::sync::Notify;use tracing::Event;use tracing::Subscriber;use tracing::field::Visit;use tracing_subscriber::Layer;use tracing_subscriber::layer::Context as LayerContext;use tracing_subscriber::layer::SubscriberExt;use tracing_subscriber::registry::LookupSpan;use tracing_subscriber::util::SubscriberInitExt;const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";fn test_model_client(session_source: SessionSource) -> ModelClient {    let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses);    let thread_id = ThreadId::new();    ModelClient::new(        /*auth_manager*/ None,        thread_id,        provider,        session_source,        /*model_verbosity*/ None,        /*enable_request_compression*/ false,        /*include_timing_metrics*/ false,        /*beta_features_header*/ None,        /*attestation_provider*/ None,    )}fn test_responses_metadata_for_client(    client: &ModelClient,    turn_id: Option<&str>,    window_id: String,    parent_thread_id: Option<ThreadId>,    request_kind: TestCodexResponsesRequestKind,) -> CodexResponsesMetadata {    let thread_id = client.state.thread_id.to_string();    test_responses_metadata(        TEST_INSTALLATION_ID,        &thread_id,        &thread_id,        turn_id,        window_id,        &client.state.session_source,        parent_thread_id,        request_kind,    )}fn test_model_info() -> ModelInfo {    serde_json::from_value(json!({        "slug": "gpt-test",        "display_name": "gpt-test",        "description": "desc",        "default_reasoning_level": "medium",        "supported_reasoning_levels": [            {"effort": "medium", "description": "medium"}        ],        "shell_type": "shell_command",        "visibility": "list",        "supported_in_api": true,        "priority": 1,        "upgrade": null,        "base_instructions": "base instructions",        "model_messages": null,        "supports_reasoning_summaries": false,        "support_verbosity": false,        "default_verbosity": null,        "apply_patch_tool_type": null,        "truncation_policy": {"mode": "bytes", "limit": 10000},        "supports_parallel_tool_calls": false,        "supports_image_detail_original": false,        "context_window": 272000,        "auto_compact_token_limit": null,        "experimental_supported_tools": []    }))    .expect("deserialize test model info")}fn test_session_telemetry() -> SessionTelemetry {    SessionTelemetry::new(        ThreadId::new(),        "gpt-test",        "gpt-test",        /*account_id*/ None,        /*account_email*/ None,        /*auth_mode*/ None,        "test-originator".to_string(),        /*log_user_prompts*/ false,        "test-terminal".to_string(),        SessionSource::Cli,    )}#[derive(Default)]struct TagCollectorVisitor {    tags: BTreeMap<String, String>,}impl Visit for TagCollectorVisitor {    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {        self.tags            .insert(field.name().to_string(), value.to_string());    }    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {        self.tags            .insert(field.name().to_string(), format!("{value:?}"));    }}#[derive(Clone)]struct TagCollectorLayer {    tags: Arc<Mutex<BTreeMap<String, String>>>,}impl<S> Layer<S> for TagCollectorLayerwhere    S: Subscriber + for<'a> LookupSpan<'a>,{    fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {        if event.metadata().target() != "feedback_tags" {            return;        }        let mut visitor = TagCollectorVisitor::default();        event.record(&mut visitor);        self.tags.lock().unwrap().extend(visitor.tags);    }}fn started_inference_attempt(temp: &TempDir) -> anyhow::Result<InferenceTraceAttempt> {    let writer = Arc::new(TraceWriter::create(        temp.path(),        "trace-1".to_string(),        "rollout-1".to_string(),        "thread-root".to_string(),    )?);    writer.append(RawTraceEventPayload::ThreadStarted {        thread_id: "thread-root".to_string(),        agent_path: "/root".to_string(),        metadata_payload: None,    })?;    writer.append(RawTraceEventPayload::CodexTurnStarted {        codex_turn_id: "turn-1".to_string(),        thread_id: "thread-root".to_string(),    })?;    let inference_trace = InferenceTraceContext::enabled(        writer,        "thread-root".to_string(),        "turn-1".to_string(),        "gpt-test".to_string(),        "test-provider".to_string(),    );    let attempt = inference_trace.start_attempt();    attempt.record_started(&json!({        "model": "gpt-test",        "input": [{            "type": "message",            "role": "user",            "content": [{"type": "input_text", "text": "hello"}]        }],    }));    Ok(attempt)}fn output_message(id: &str, text: &str) -> ResponseItem {    ResponseItem::Message {        id: Some(id.to_string()),        role: "assistant".to_string(),        content: vec![ContentItem::OutputText {            text: text.to_string(),        }],        phase: None,        metadata: None,    }}async fn replay_until_cancelled(temp: &TempDir) -> anyhow::Result<RolloutTrace> {    let mut rollout = replay_bundle(temp.path())?;    for _ in 0..50 {        let inference = rollout            .inference_calls            .values()            .next()            .expect("inference should be reduced");        if inference.execution.status == ExecutionStatus::Cancelled {            return Ok(rollout);        }        tokio::time::sleep(Duration::from_millis(10)).await;        rollout = replay_bundle(temp.path())?;    }    Ok(rollout)}struct NotifyAfterEventStream {    events: VecDeque<ResponseEvent>,    yielded: usize,    notify_after: usize,    notify: Arc<Notify>,}impl futures::Stream for NotifyAfterEventStream {    type Item = std::result::Result<ResponseEvent, ApiError>;    fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {        let Some(event) = self.events.pop_front() else {            return Poll::Pending;        };        self.yielded += 1;        if self.yielded == self.notify_after {            self.notify.notify_one();        }        Poll::Ready(Some(Ok(event)))    }}#[test]fn build_subagent_headers_sets_other_subagent_label() {    let client = test_model_client(SessionSource::SubAgent(SubAgentSource::Other(        "memory_consolidation".to_string(),    )));    let headers = client.build_subagent_headers();    let value = headers        .get(X_OPENAI_SUBAGENT_HEADER)        .and_then(|value| value.to_str().ok());    assert_eq!(value, Some("memory_consolidation"));}#[test]fn build_subagent_headers_sets_internal_memory_consolidation_label() {    let client = test_model_client(SessionSource::Internal(        InternalSessionSource::MemoryConsolidation,    ));    let headers = client.build_subagent_headers();    let value = headers        .get(X_OPENAI_SUBAGENT_HEADER)        .and_then(|value| value.to_str().ok());    assert_eq!(value, Some("memory_consolidation"));}#[test]fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {    let parent_thread_id = ThreadId::new();    let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {        parent_thread_id,        depth: 2,        agent_path: None,        agent_nickname: None,        agent_role: None,    }));    let thread_id = client.state.thread_id.to_string();    let expected_window_id = format!("{thread_id}:1");    let responses_metadata = test_responses_metadata_for_client(        &client,        Some("turn-123"),        expected_window_id.clone(),        Some(parent_thread_id),        TestCodexResponsesRequestKind::Turn,    );    let client_metadata =        client.build_ws_client_metadata(&responses_metadata, /*use_responses_lite*/ false);    let parent_thread_id = parent_thread_id.to_string();    let turn_metadata: serde_json::Value = serde_json::from_str(        client_metadata            .get(X_CODEX_TURN_METADATA_HEADER)            .expect("turn metadata"),    )    .expect("valid turn metadata");    for (client_key, metadata_key, expected) in [        (            X_CODEX_INSTALLATION_ID_HEADER,            "installation_id",            "11111111-1111-4111-8111-111111111111",        ),        ("session_id", "session_id", thread_id.as_str()),        ("thread_id", "thread_id", thread_id.as_str()),        ("turn_id", "turn_id", "turn-123"),        (            X_CODEX_WINDOW_ID_HEADER,            "window_id",            expected_window_id.as_str(),        ),        (            X_CODEX_PARENT_THREAD_ID_HEADER,            "parent_thread_id",            parent_thread_id.as_str(),        ),    ] {        assert_eq!(            client_metadata.get(client_key).map(String::as_str),            Some(expected)        );        assert_eq!(turn_metadata[metadata_key].as_str(), Some(expected));    }    assert_eq!(        client_metadata            .get(X_OPENAI_SUBAGENT_HEADER)            .map(String::as_str),        Some("collab_spawn")    );}#[tokio::test]async fn summarize_memories_returns_empty_for_empty_input() {    let client = test_model_client(SessionSource::Cli);    let model_info = test_model_info();    let session_telemetry = test_session_telemetry();    let output = client        .summarize_memories(            Vec::new(),            &model_info,            /*effort*/ None,            &session_telemetry,        )        .await        .expect("empty summarize request should succeed");    assert_eq!(output.len(), 0);}#[tokio::test]async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> {    let temp = TempDir::new()?;    let attempt = started_inference_attempt(&temp)?;    // The provider has produced one complete output item, but no terminal    // response.completed event. The harness has enough information to keep this    // item in history, so the trace should preserve it when the stream is    // abandoned.    let item = output_message("msg-1", "partial answer");    let api_stream = futures::stream::iter([Ok(ResponseEvent::OutputItemDone(item))])        .chain(futures::stream::pending());    let (mut stream, _) = super::map_response_events(        /*upstream_request_id*/ None,        api_stream,        test_session_telemetry(),        attempt,    );    let observed = stream        .next()        .await        .expect("mapped stream should yield output item")?;    assert!(matches!(observed, ResponseEvent::OutputItemDone(_)));    // Dropping the consumer is how turn interruption/preemption stops polling    // the provider stream. The mapper task observes that drop asynchronously    // and records cancellation using the output items it has already seen.    drop(stream);    // Cancellation is recorded by the mapper task after Drop wakes it, so the    // replay may need a short wait before the terminal event appears on disk.    let rollout = replay_until_cancelled(&temp).await?;    let inference = rollout        .inference_calls        .values()        .next()        .expect("inference should be reduced");    assert_eq!(inference.execution.status, ExecutionStatus::Cancelled);    assert_eq!(inference.response_item_ids.len(), 1);    assert_eq!(rollout.raw_payloads.len(), 2);    Ok(())}#[tokio::test]async fn response_stream_records_last_model_feedback_ids() {    let tags = Arc::new(Mutex::new(BTreeMap::new()));    let _guard = tracing_subscriber::registry()        .with(TagCollectorLayer { tags: tags.clone() })        .set_default();    let api_stream = futures::stream::iter([        Ok(ResponseEvent::Created),        Ok(ResponseEvent::Completed {            response_id: "resp-123".to_string(),            token_usage: None,            end_turn: Some(true),        }),    ]);    let (mut stream, _) = super::map_response_events(        Some("req-123".to_string()),        api_stream,        test_session_telemetry(),        InferenceTraceAttempt::disabled(),    );    while stream.next().await.is_some() {}    let tags = tags.lock().unwrap().clone();    assert_eq!(        tags.get("last_model_request_id").map(String::as_str),        Some("\"req-123\"")    );    assert_eq!(        tags.get("last_model_response_id").map(String::as_str),        Some("\"resp-123\"")    );}#[tokio::test]async fn dropped_backpressured_response_stream_traces_cancelled_partial_output()-> anyhow::Result<()> {    let temp = TempDir::new()?;    let attempt = started_inference_attempt(&temp)?;    let backpressured_item_yielded = Arc::new(Notify::new());    let mut events = VecDeque::new();    for _ in 0..super::RESPONSE_STREAM_CHANNEL_CAPACITY {        events.push_back(ResponseEvent::Created);    }    events.push_back(ResponseEvent::OutputItemDone(output_message(        "msg-1",        "partial answer",    )));    let api_stream = NotifyAfterEventStream {        events,        yielded: 0,        notify_after: super::RESPONSE_STREAM_CHANNEL_CAPACITY + 1,        notify: Arc::clone(&backpressured_item_yielded),    };    let (stream, _) = super::map_response_events(        /*upstream_request_id*/ None,        api_stream,        test_session_telemetry(),        attempt,    );    // Fill the mapper channel with non-terminal events, then yield one output    // item. The mapper has observed that item and is blocked trying to send it    // downstream, so dropping the consumer covers the send-failure path rather    // than the `consumer_dropped` select branch.    backpressured_item_yielded.notified().await;    drop(stream);    let rollout = replay_until_cancelled(&temp).await?;    let inference = rollout        .inference_calls        .values()        .next()        .expect("inference should be reduced");    assert_eq!(inference.execution.status, ExecutionStatus::Cancelled);    assert_eq!(inference.response_item_ids.len(), 1);    assert_eq!(rollout.raw_payloads.len(), 2);    Ok(())}#[test]fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {    let auth_context = AuthRequestTelemetryContext::new(        Some(AuthMode::Chatgpt),        &BearerAuthProvider::for_test(Some("access-token"), Some("workspace-123")),        PendingUnauthorizedRetry::from_recovery(UnauthorizedRecoveryExecution {            mode: "managed",            phase: "refresh_token",        }),    );    assert_eq!(auth_context.auth_mode, Some("Chatgpt"));    assert!(auth_context.auth_header_attached);    assert_eq!(auth_context.auth_header_name, Some("authorization"));    assert!(auth_context.retry_after_unauthorized);    assert_eq!(auth_context.recovery_mode, Some("managed"));    assert_eq!(auth_context.recovery_phase, Some("refresh_token"));}fn model_client_with_counting_attestation(    include_attestation: bool,) -> (ModelClient, Arc<AtomicUsize>) {    #[derive(Debug)]    struct CountingAttestationProvider {        calls: Arc<AtomicUsize>,    }    impl AttestationProvider for CountingAttestationProvider {        fn header_for_request(            &self,            _context: AttestationContext,        ) -> GenerateAttestationFuture<'_> {            let calls = self.calls.clone();            Box::pin(async move {                let call = calls.fetch_add(1, Ordering::Relaxed) + 1;                Some(http::HeaderValue::from_bytes(format!("v1.header-{call}").as_bytes()).unwrap())            })        }    }    let attestation_calls = Arc::new(AtomicUsize::new(0));    let (auth_manager, provider) = if include_attestation {        (            Some(AuthManager::from_auth_for_testing(                CodexAuth::create_dummy_chatgpt_auth_for_testing(),            )),            ModelProviderInfo::create_openai_provider(Some(CHATGPT_CODEX_BASE_URL.to_string())),        )    } else {        (            None,            create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses),        )    };    let model_client = ModelClient::new(        auth_manager,        ThreadId::new(),        provider,        SessionSource::Exec,        /*model_verbosity*/ None,        /*enable_request_compression*/ false,        /*include_timing_metrics*/ false,        /*beta_features_header*/ None,        Some(Arc::new(CountingAttestationProvider {            calls: attestation_calls.clone(),        })),    );    (model_client, attestation_calls)}#[tokio::test]async fn websocket_handshake_includes_attestation_for_chatgpt_codex_responses() {    let (model_client, attestation_calls) =        model_client_with_counting_attestation(/*include_attestation*/ true);    let responses_metadata = test_responses_metadata_for_client(        &model_client,        /*turn_id*/ None,        format!("{}:0", model_client.state.thread_id),        /*parent_thread_id*/ None,        TestCodexResponsesRequestKind::WebsocketConnection,    );    let headers = model_client        .build_websocket_headers(&responses_metadata)        .await;    assert_eq!(        headers            .get(crate::attestation::X_OAI_ATTESTATION_HEADER)            .and_then(|value| value.to_str().ok()),        Some("v1.header-1"),    );    assert_eq!(attestation_calls.load(Ordering::Relaxed), 1);}#[tokio::test]async fn non_chatgpt_codex_endpoints_omit_attestation_generation() {    let (model_client, attestation_calls) =        model_client_with_counting_attestation(/*include_attestation*/ false);    let mut response_headers = http::HeaderMap::new();    if let Some(header_value) = model_client.generate_attestation_header_for().await {        response_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);    }    let mut compaction_headers = http::HeaderMap::new();    if let Some(header_value) = model_client.generate_attestation_header_for().await {        compaction_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);    }    let mut realtime_headers = http::HeaderMap::new();    if let Some(header_value) = model_client.generate_attestation_header_for().await {        realtime_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);    }    assert_eq!(        response_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER),        None,    );    assert_eq!(        compaction_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER),        None,    );    assert_eq!(        realtime_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER),        None,    );    assert_eq!(attestation_calls.load(Ordering::Relaxed), 0);}