mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
1acb722e8a
## Why Work(TPP) threads can be launched from the Desktop app, but if they all keep the Desktop app's default originator then downstream attribution cannot distinguish local Work launches from cloud-backed Work launches. `thread/start.serviceName` already carries that launch signal, while `SessionMeta.originator` is the durable thread-level value that survives resume and fork. This change converts the Desktop Work service names into an effective originator at thread creation time, persists that originator with the thread, and keeps using it for later model requests and memory writes. ## What changed - Map `CODEX_WORK_LOCAL` and `CODEX_WORK_CLOUD` service names to per-thread originators, while preserving `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` as the highest-precedence override. - Persist the effective originator in `SessionMeta.originator`, read it back on resume/fork, and inherit the parent originator for subagent spawns when there is no persisted session metadata. - Handle truncated `SpawnAgentForkMode::LastNTurns` forks by falling back to the live parent originator when the forked history no longer includes `SessionMeta`. - Thread the per-thread originator through Responses headers, websocket/compaction request paths, thread-store creation, rollout metadata, and memory stage-one telemetry. ## Verification - `just test -p codex-core agent::control::tests::spawn_thread_subagent_inherits_parent_originator_without_fork agent::control::tests::spawn_thread_subagent_fork_last_n_turns_inherits_parent_originator_without_session_meta thread_manager::tests::originator_override_precedes_service_name_remapping` - `just test -p codex-core agent::control::tests::resume_thread_subagent_restores_stored_metadata_and_effective_multi_agent_mode` - `just test -p codex-memories-write` - `just fix -p codex-core -p codex-memories-write` - `git diff --check`
658 lines
22 KiB
Rust
658 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_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,
|
|
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,
|
|
/*attestation_provider*/ None,
|
|
);
|
|
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,
|
|
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,
|
|
/*attestation_provider*/ None,
|
|
);
|
|
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,
|
|
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,
|
|
/*attestation_provider*/ None,
|
|
);
|
|
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)
|
|
);
|
|
}
|