mirror of
https://github.com/openai/codex.git
synced 2026-09-03 14:59:03 +00:00
## Why `features.respect_system_proxy` already routes authentication traffic through the OS proxy APIs, but it does not affect the primary inference path. That leaves users behind OS-managed proxies unable to send normal Responses API requests even after login succeeds. This PR is the first product-path migration onto the route-aware transport introduced in #31323 and refined in #31331. It also establishes the construction pattern for later migrations: the effective feature state is resolved once into a required HTTP client factory rather than represented by an optional per-call setting. The scope remains limited to the two HTTP Responses endpoints; WebSockets, model discovery, memories, realtime, and file uploads remain follow-up migrations. ## What changed - Replace the optional proxy marker with an explicit `OutboundProxyPolicy::{ReqwestDefault, RespectSystemProxy}` and a required `HttpClientFactory`. The policy has no default, and the lower-level route-aware reqwest builder is now private. - Have `Config` construct the factory from the effective feature state and require every `ModelClient` constructor to receive it. There is no optional setter or implicit `None` fallback. - Build HTTP clients for `/responses` and `/responses/compact` with `ClientRouteClass::Api`, using the complete destination URL so PAC rules can make URL-specific decisions. - Layer route-aware selection onto Codex's existing default headers, Cloudflare cookie store, custom CA handling, and sandbox no-proxy behavior. - Add an integration test that loads `features.respect_system_proxy` through `config.toml`, creates a real Codex session, and verifies that both a normal Responses turn and remote compaction reach an isolated local proxy. ## Review guide 1. `http-client/src/outbound_proxy.rs` defines the mandatory policy/factory boundary and keeps route resolution private. 2. `core/src/config/mod.rs`, `core/src/session/session.rs`, and `core/src/client.rs` show the compile-time invariant: effective config creates the factory, and `ModelClient` cannot be constructed without one. 3. `login/src/auth/default_client.rs` preserves existing default-client behavior while accepting the required factory for migrated routes. 4. `core/src/client.rs` switches only streaming Responses and remote compaction HTTP transports to the API route class. 5. `core/tests/suite/responses_api_system_proxy.rs` is the behavioral regression boundary. Its Linux subprocess deliberately sets the CGI marker that disables reqwest's implicit environment-proxy handling, so the test fails if session wiring or either Responses call site falls back to the default client. ## Test plan - `cargo check --tests -p codex-http-client -p codex-login -p codex-core` - `just test -p codex-login` - `just test -p codex-core respect_system_proxy_feature_resolves_enabled` - Existing `compact_uses_bearer_after_agent_identity_session_fallback` coverage passes with the new transport construction. - New Linux integration coverage: `responses_and_compact_use_enabled_system_proxy` - `just bazel-lock-check` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/31335). * #31342 * __->__ #31335
668 lines
22 KiB
Rust
668 lines
22 KiB
Rust
use std::process::Command;
|
|
use std::sync::Arc;
|
|
|
|
use codex_core::ModelClient;
|
|
use codex_core::Prompt;
|
|
use codex_core::ResponseEvent;
|
|
use codex_login::CodexAuth;
|
|
use codex_login::auth::AgentIdentityAuthPolicy;
|
|
use codex_model_provider_info::ModelProviderInfo;
|
|
use codex_model_provider_info::WireApi;
|
|
use codex_otel::SessionTelemetry;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use core_test_support::TestCodexResponsesRequestKind;
|
|
use core_test_support::load_default_config_for_test;
|
|
use core_test_support::responses;
|
|
use core_test_support::responses_metadata as test_responses_metadata;
|
|
use core_test_support::test_codex::test_codex;
|
|
use futures::StreamExt;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
use wiremock::matchers::header;
|
|
|
|
fn normalize_git_remote_url(url: &str) -> String {
|
|
let normalized = url.trim().trim_end_matches('/');
|
|
normalized
|
|
.strip_suffix(".git")
|
|
.unwrap_or(normalized)
|
|
.to_string()
|
|
}
|
|
|
|
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
|
|
fn test_turn_responses_metadata(
|
|
_client: &ModelClient,
|
|
thread_id: ThreadId,
|
|
session_source: &SessionSource,
|
|
) -> codex_core::CodexResponsesMetadata {
|
|
let thread_id = thread_id.to_string();
|
|
test_responses_metadata(
|
|
TEST_INSTALLATION_ID,
|
|
&thread_id,
|
|
&thread_id,
|
|
/*turn_id*/ None,
|
|
format!("{thread_id}:0"),
|
|
session_source,
|
|
/*parent_thread_id*/ None,
|
|
TestCodexResponsesRequestKind::Turn,
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_stream_includes_subagent_header_on_review() {
|
|
core_test_support::skip_if_no_network!();
|
|
|
|
let server = responses::start_mock_server().await;
|
|
let response_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_completed("resp-1"),
|
|
]);
|
|
|
|
let request_recorder = responses::mount_sse_once_match(
|
|
&server,
|
|
header("x-openai-subagent", "review"),
|
|
response_body,
|
|
)
|
|
.await;
|
|
|
|
let provider = ModelProviderInfo {
|
|
name: "mock".into(),
|
|
base_url: Some(format!("{}/v1", server.uri())),
|
|
env_key: None,
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: Some(0),
|
|
stream_max_retries: Some(0),
|
|
stream_idle_timeout_ms: Some(5_000),
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
let codex_home = TempDir::new().expect("failed to create TempDir");
|
|
let mut config = load_default_config_for_test(&codex_home).await;
|
|
config.model_provider_id = provider.name.clone();
|
|
config.model_provider = provider.clone();
|
|
let effort = config.model_reasoning_effort.clone();
|
|
let summary = config.model_reasoning_summary;
|
|
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
|
|
config.model = Some(model.clone());
|
|
let config = Arc::new(config);
|
|
|
|
let thread_id = ThreadId::new();
|
|
let auth_mode = TelemetryAuthMode::Chatgpt;
|
|
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
|
|
let model_info =
|
|
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
|
|
let expected_window_id = format!("{thread_id}:0");
|
|
let session_telemetry = SessionTelemetry::new(
|
|
thread_id,
|
|
model.as_str(),
|
|
model_info.slug.as_str(),
|
|
/*account_id*/ None,
|
|
Some("test@test.com".to_string()),
|
|
Some(auth_mode),
|
|
"test_originator".to_string(),
|
|
/*log_user_prompts*/ false,
|
|
"test".to_string(),
|
|
session_source.clone(),
|
|
);
|
|
|
|
let client = ModelClient::new(
|
|
/*auth_manager*/ None,
|
|
AgentIdentityAuthPolicy::JwtOnly,
|
|
thread_id,
|
|
provider.clone(),
|
|
session_source.clone(),
|
|
"test_originator".to_string(),
|
|
config.model_verbosity,
|
|
/*enable_request_compression*/ false,
|
|
/*include_timing_metrics*/ false,
|
|
/*beta_features_header*/ None,
|
|
/*item_ids_enabled*/ false,
|
|
/*concurrent_reasoning_summaries_enabled*/ false,
|
|
/*attestation_provider*/ None,
|
|
config.http_client_factory(),
|
|
);
|
|
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
|
|
let mut client_session = client.new_session();
|
|
|
|
let mut prompt = Prompt::default();
|
|
prompt.input = vec![ResponseItem::Message {
|
|
id: None,
|
|
role: "user".into(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "hello".into(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}];
|
|
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&model_info,
|
|
&session_telemetry,
|
|
effort,
|
|
summary.unwrap_or(model_info.default_reasoning_summary),
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("stream failed");
|
|
while let Some(event) = stream.next().await {
|
|
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let request = request_recorder.single_request();
|
|
assert_eq!(
|
|
request.header("x-openai-subagent").as_deref(),
|
|
Some("review")
|
|
);
|
|
assert_eq!(
|
|
request.header("x-codex-window-id").as_deref(),
|
|
Some(expected_window_id.as_str())
|
|
);
|
|
assert_eq!(request.header("x-codex-parent-thread-id"), None);
|
|
assert_eq!(
|
|
request.body_json()["client_metadata"]["x-codex-installation-id"].as_str(),
|
|
Some(TEST_INSTALLATION_ID)
|
|
);
|
|
assert_eq!(
|
|
request.body_json()["client_metadata"]["x-codex-window-id"].as_str(),
|
|
Some(expected_window_id.as_str())
|
|
);
|
|
assert_eq!(request.header("x-codex-sandbox"), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_stream_includes_subagent_header_on_other() {
|
|
core_test_support::skip_if_no_network!();
|
|
|
|
let server = responses::start_mock_server().await;
|
|
let response_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_completed("resp-1"),
|
|
]);
|
|
|
|
let request_recorder = responses::mount_sse_once_match(
|
|
&server,
|
|
header("x-openai-subagent", "my-task"),
|
|
response_body,
|
|
)
|
|
.await;
|
|
|
|
let provider = ModelProviderInfo {
|
|
name: "mock".into(),
|
|
base_url: Some(format!("{}/v1", server.uri())),
|
|
env_key: None,
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: Some(0),
|
|
stream_max_retries: Some(0),
|
|
stream_idle_timeout_ms: Some(5_000),
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
let codex_home = TempDir::new().expect("failed to create TempDir");
|
|
let mut config = load_default_config_for_test(&codex_home).await;
|
|
config.model_provider_id = provider.name.clone();
|
|
config.model_provider = provider.clone();
|
|
let effort = config.model_reasoning_effort.clone();
|
|
let summary = config.model_reasoning_summary;
|
|
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
|
|
config.model = Some(model.clone());
|
|
let config = Arc::new(config);
|
|
|
|
let thread_id = ThreadId::new();
|
|
let auth_mode = TelemetryAuthMode::Chatgpt;
|
|
let session_source = SessionSource::SubAgent(SubAgentSource::Other("my-task".to_string()));
|
|
let model_info =
|
|
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
|
|
|
|
let session_telemetry = SessionTelemetry::new(
|
|
thread_id,
|
|
model.as_str(),
|
|
model_info.slug.as_str(),
|
|
/*account_id*/ None,
|
|
Some("test@test.com".to_string()),
|
|
Some(auth_mode),
|
|
"test_originator".to_string(),
|
|
/*log_user_prompts*/ false,
|
|
"test".to_string(),
|
|
session_source.clone(),
|
|
);
|
|
|
|
let client = ModelClient::new(
|
|
/*auth_manager*/ None,
|
|
AgentIdentityAuthPolicy::JwtOnly,
|
|
thread_id,
|
|
provider.clone(),
|
|
session_source.clone(),
|
|
"test_originator".to_string(),
|
|
config.model_verbosity,
|
|
/*enable_request_compression*/ false,
|
|
/*include_timing_metrics*/ false,
|
|
/*beta_features_header*/ None,
|
|
/*item_ids_enabled*/ false,
|
|
/*concurrent_reasoning_summaries_enabled*/ false,
|
|
/*attestation_provider*/ None,
|
|
config.http_client_factory(),
|
|
);
|
|
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
|
|
let mut client_session = client.new_session();
|
|
|
|
let mut prompt = Prompt::default();
|
|
prompt.input = vec![ResponseItem::Message {
|
|
id: None,
|
|
role: "user".into(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "hello".into(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}];
|
|
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&model_info,
|
|
&session_telemetry,
|
|
effort,
|
|
summary.unwrap_or(model_info.default_reasoning_summary),
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("stream failed");
|
|
while let Some(event) = stream.next().await {
|
|
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let request = request_recorder.single_request();
|
|
assert_eq!(
|
|
request.header("x-openai-subagent").as_deref(),
|
|
Some("my-task")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_respects_model_info_overrides_from_config() {
|
|
core_test_support::skip_if_no_network!();
|
|
|
|
let server = responses::start_mock_server().await;
|
|
let response_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_completed("resp-1"),
|
|
]);
|
|
|
|
let request_recorder = responses::mount_sse_once(&server, response_body).await;
|
|
|
|
let provider = ModelProviderInfo {
|
|
name: "mock".into(),
|
|
base_url: Some(format!("{}/v1", server.uri())),
|
|
env_key: None,
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: Some(0),
|
|
stream_max_retries: Some(0),
|
|
stream_idle_timeout_ms: Some(5_000),
|
|
websocket_connect_timeout_ms: None,
|
|
requires_openai_auth: false,
|
|
supports_websockets: false,
|
|
};
|
|
|
|
let codex_home = TempDir::new().expect("failed to create TempDir");
|
|
let mut config = load_default_config_for_test(&codex_home).await;
|
|
config.model = Some("gpt-3.5-turbo".to_string());
|
|
config.model_provider_id = provider.name.clone();
|
|
config.model_provider = provider.clone();
|
|
config.model_supports_reasoning_summaries = Some(true);
|
|
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
|
|
let effort = config.model_reasoning_effort.clone();
|
|
let summary = config.model_reasoning_summary;
|
|
let model = config.model.clone().expect("model configured");
|
|
let config = Arc::new(config);
|
|
|
|
let thread_id = ThreadId::new();
|
|
let auth_mode =
|
|
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key"))
|
|
.auth_mode()
|
|
.map(TelemetryAuthMode::from);
|
|
let session_source =
|
|
SessionSource::SubAgent(SubAgentSource::Other("override-check".to_string()));
|
|
let model_info =
|
|
codex_core::test_support::construct_model_info_offline(model.as_str(), &config);
|
|
let session_telemetry = SessionTelemetry::new(
|
|
thread_id,
|
|
model.as_str(),
|
|
model_info.slug.as_str(),
|
|
/*account_id*/ None,
|
|
Some("test@test.com".to_string()),
|
|
auth_mode,
|
|
"test_originator".to_string(),
|
|
/*log_user_prompts*/ false,
|
|
"test".to_string(),
|
|
session_source.clone(),
|
|
);
|
|
|
|
let client = ModelClient::new(
|
|
/*auth_manager*/ None,
|
|
AgentIdentityAuthPolicy::JwtOnly,
|
|
thread_id,
|
|
provider.clone(),
|
|
session_source.clone(),
|
|
"test_originator".to_string(),
|
|
config.model_verbosity,
|
|
/*enable_request_compression*/ false,
|
|
/*include_timing_metrics*/ false,
|
|
/*beta_features_header*/ None,
|
|
/*item_ids_enabled*/ false,
|
|
/*concurrent_reasoning_summaries_enabled*/ false,
|
|
/*attestation_provider*/ None,
|
|
config.http_client_factory(),
|
|
);
|
|
let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source);
|
|
let mut client_session = client.new_session();
|
|
|
|
let mut prompt = Prompt::default();
|
|
prompt.input = vec![ResponseItem::Message {
|
|
id: None,
|
|
role: "user".into(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "hello".into(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
}];
|
|
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&model_info,
|
|
&session_telemetry,
|
|
effort,
|
|
summary.unwrap_or(model_info.default_reasoning_summary),
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("stream failed");
|
|
while let Some(event) = stream.next().await {
|
|
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let request = request_recorder.single_request();
|
|
let body = request.body_json();
|
|
let reasoning = body
|
|
.get("reasoning")
|
|
.and_then(|value| value.as_object())
|
|
.cloned();
|
|
|
|
assert!(
|
|
reasoning.is_some(),
|
|
"reasoning should be present when config enables summaries"
|
|
);
|
|
|
|
assert_eq!(
|
|
reasoning
|
|
.as_ref()
|
|
.and_then(|value| value.get("summary"))
|
|
.and_then(|value| value.as_str()),
|
|
Some("detailed")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() {
|
|
core_test_support::skip_if_no_network!();
|
|
|
|
let server = responses::start_mock_server().await;
|
|
let response_body = responses::sse(vec![
|
|
responses::ev_response_created("resp-1"),
|
|
responses::ev_completed("resp-1"),
|
|
]);
|
|
|
|
let test = test_codex().build(&server).await.expect("build test codex");
|
|
let cwd = test.cwd_path();
|
|
|
|
let first_request = responses::mount_sse_once(&server, response_body.clone()).await;
|
|
test.submit_turn("hello")
|
|
.await
|
|
.expect("submit first turn prompt");
|
|
let initial_header = first_request
|
|
.single_request()
|
|
.header("x-codex-turn-metadata")
|
|
.expect("x-codex-turn-metadata header should be present");
|
|
let initial_parsed: serde_json::Value =
|
|
serde_json::from_str(&initial_header).expect("x-codex-turn-metadata should be valid JSON");
|
|
let initial_turn_id = initial_parsed
|
|
.get("turn_id")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("turn_id should be present")
|
|
.to_string();
|
|
assert!(
|
|
!initial_turn_id.is_empty(),
|
|
"turn_id should not be empty in x-codex-turn-metadata"
|
|
);
|
|
let initial_turn_started_at_unix_ms = initial_parsed
|
|
.get("turn_started_at_unix_ms")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.expect("turn_started_at_unix_ms should be present");
|
|
assert!(
|
|
initial_turn_started_at_unix_ms > 0,
|
|
"turn_started_at_unix_ms should be positive"
|
|
);
|
|
assert_eq!(
|
|
initial_parsed
|
|
.get("sandbox")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("none")
|
|
);
|
|
assert_eq!(
|
|
initial_parsed
|
|
.get("thread_source")
|
|
.and_then(serde_json::Value::as_str),
|
|
None
|
|
);
|
|
|
|
let git_config_global = cwd.join("empty-git-config");
|
|
std::fs::write(&git_config_global, "").expect("write empty git config");
|
|
let run_git = |args: &[&str]| {
|
|
let output = Command::new("git")
|
|
.env("GIT_CONFIG_GLOBAL", &git_config_global)
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.output()
|
|
.expect("git command should run");
|
|
assert!(
|
|
output.status.success(),
|
|
"git {:?} failed: stdout={} stderr={}",
|
|
args,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
output
|
|
};
|
|
|
|
run_git(&["init"]);
|
|
run_git(&["config", "user.name", "Test User"]);
|
|
run_git(&["config", "user.email", "test@example.com"]);
|
|
std::fs::write(cwd.join("README.md"), "hello").expect("write README");
|
|
run_git(&["add", "."]);
|
|
run_git(&["commit", "-m", "initial commit"]);
|
|
run_git(&[
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
"https://github.com/openai/codex.git",
|
|
]);
|
|
|
|
let expected_head = String::from_utf8(run_git(&["rev-parse", "HEAD"]).stdout)
|
|
.expect("git rev-parse output should be valid UTF-8")
|
|
.trim()
|
|
.to_string();
|
|
let expected_origin = String::from_utf8(run_git(&["remote", "get-url", "origin"]).stdout)
|
|
.expect("git remote get-url output should be valid UTF-8")
|
|
.trim()
|
|
.to_string();
|
|
|
|
let first_response = responses::sse(vec![
|
|
responses::ev_response_created("resp-2"),
|
|
responses::ev_reasoning_item("rsn-1", &["thinking"], &[]),
|
|
responses::ev_shell_command_call("call-1", "echo turn-metadata"),
|
|
responses::ev_completed("resp-2"),
|
|
]);
|
|
let follow_up_response = responses::sse(vec![
|
|
responses::ev_response_created("resp-3"),
|
|
responses::ev_assistant_message("msg-1", "done"),
|
|
responses::ev_completed("resp-3"),
|
|
]);
|
|
let request_log = responses::mount_response_sequence(
|
|
&server,
|
|
vec![
|
|
responses::sse_response(first_response),
|
|
responses::sse_response(follow_up_response),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.submit_turn("hello")
|
|
.await
|
|
.expect("submit post-git turn prompt");
|
|
|
|
let requests = request_log.requests();
|
|
assert_eq!(requests.len(), 2, "expected two requests in one turn");
|
|
|
|
let first_parsed: serde_json::Value = serde_json::from_str(
|
|
&requests[0]
|
|
.header("x-codex-turn-metadata")
|
|
.expect("first request should include turn metadata"),
|
|
)
|
|
.expect("first metadata should be valid json");
|
|
let second_parsed: serde_json::Value = serde_json::from_str(
|
|
&requests[1]
|
|
.header("x-codex-turn-metadata")
|
|
.expect("second request should include turn metadata"),
|
|
)
|
|
.expect("second metadata should be valid json");
|
|
|
|
let first_turn_id = first_parsed
|
|
.get("turn_id")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("first turn_id should be present");
|
|
let second_turn_id = second_parsed
|
|
.get("turn_id")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("second turn_id should be present");
|
|
let first_turn_started_at_unix_ms = first_parsed
|
|
.get("turn_started_at_unix_ms")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.expect("first turn_started_at_unix_ms should be present");
|
|
let second_turn_started_at_unix_ms = second_parsed
|
|
.get("turn_started_at_unix_ms")
|
|
.and_then(serde_json::Value::as_i64)
|
|
.expect("second turn_started_at_unix_ms should be present");
|
|
assert!(
|
|
first_turn_started_at_unix_ms > 0,
|
|
"first turn_started_at_unix_ms should be positive"
|
|
);
|
|
assert_eq!(
|
|
first_turn_started_at_unix_ms, second_turn_started_at_unix_ms,
|
|
"requests in the same turn should share turn_started_at_unix_ms"
|
|
);
|
|
assert_eq!(
|
|
first_parsed
|
|
.get("thread_source")
|
|
.and_then(serde_json::Value::as_str),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
second_parsed
|
|
.get("thread_source")
|
|
.and_then(serde_json::Value::as_str),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
first_turn_id, second_turn_id,
|
|
"requests should share turn_id"
|
|
);
|
|
assert_ne!(
|
|
second_turn_id,
|
|
initial_turn_id.as_str(),
|
|
"post-git turn should have a new turn_id"
|
|
);
|
|
|
|
assert_eq!(
|
|
second_parsed
|
|
.get("sandbox")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("none")
|
|
);
|
|
|
|
let workspace = second_parsed
|
|
.get("workspaces")
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|workspaces| workspaces.values().next())
|
|
.cloned()
|
|
.expect("second request should include git workspace metadata");
|
|
assert_eq!(
|
|
workspace
|
|
.get("latest_git_commit_hash")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some(expected_head.as_str())
|
|
);
|
|
if let Some(actual_origin) = workspace
|
|
.get("associated_remote_urls")
|
|
.and_then(serde_json::Value::as_object)
|
|
.and_then(|remotes| remotes.get("origin"))
|
|
.and_then(serde_json::Value::as_str)
|
|
{
|
|
assert_eq!(
|
|
normalize_git_remote_url(actual_origin),
|
|
normalize_git_remote_url(&expected_origin)
|
|
);
|
|
}
|
|
assert_eq!(
|
|
workspace
|
|
.get("has_changes")
|
|
.and_then(serde_json::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
}
|