feat(core): store turn_id on ResponseItem metadata (#28360)

## Description

This PR is a followup to https://github.com/openai/codex/pull/28355 and
starts assigning `internal_chat_message_metadata_passthrough.turn_id` to
durable Responses API items created during a turn.

The goal is that those items keep the `turn_id` that introduced them
when Codex resends stateless HTTP context, reconstructs history for
resume/fork paths, or reuses websocket response state.

## What changed

- Set `internal_chat_message_metadata_passthrough.turn_id` when missing
as response items enter durable history, initial/replacement history,
inter-agent communication history, and local compaction summaries.
- Preserve existing item turn IDs instead of overwriting them during
persistence, resume reconstruction, compaction, forked history, and
websocket incremental reuse.
- Keep `compaction_trigger` fieldless because it is a request control,
not a durable response item.
- Update focused history/request assertions and fixtures for stateless
requests, websocket incrementals, compaction, thread injection, prompt
debug, and related CI coverage.
This commit is contained in:
Owen Lin
2026-06-22 16:45:14 -07:00
committed by GitHub
parent 7153affa0f
commit 4a82ecc3c9
27 changed files with 317 additions and 167 deletions
@@ -266,7 +266,7 @@ fn format_request_body_snapshot(
request: &ResponsesRequest,
options: &ContextSnapshotOptions,
) -> String {
let mut body = request.body_json();
let mut body = crate::responses::strip_metadata_from_json(request.body_json());
canonicalize_json_snapshot_value(&mut body, options);
serde_json::to_string_pretty(&body).expect("request body should serialize")
}
+38 -3
View File
@@ -98,6 +98,35 @@ fn decode_body_bytes(body: &[u8], content_encoding: Option<&str>) -> Vec<u8> {
}
}
/// Returns a response item without internal transport metadata for semantic assertions.
pub fn strip_metadata(mut item: ResponseItem) -> ResponseItem {
item.clear_internal_chat_message_metadata_passthrough();
item
}
/// Returns response items without internal transport metadata for semantic assertions.
pub fn strip_metadata_from_items(items: &[ResponseItem]) -> Vec<ResponseItem> {
items.iter().cloned().map(strip_metadata).collect()
}
/// Returns JSON without internal transport metadata for semantic assertions.
pub fn strip_metadata_from_json(value: Value) -> Value {
match value {
Value::Array(values) => {
Value::Array(values.into_iter().map(strip_metadata_from_json).collect())
}
Value::Object(mut map) => {
map.remove("internal_chat_message_metadata_passthrough");
Value::Object(
map.into_iter()
.map(|(key, value)| (key, strip_metadata_from_json(value)))
.collect(),
)
}
value => value,
}
}
impl ResponsesRequest {
pub fn body_json(&self) -> Value {
let body = decode_body_bytes(
@@ -1092,11 +1121,17 @@ pub async fn mount_compact_user_history_with_summary_sequence(
)
})
.collect::<Vec<Value>>();
// Append a synthetic compaction item as the newest item.
output.push(serde_json::json!({
let compaction_turn_id = body_json["client_metadata"]["turn_id"].as_str();
// Match Responses API: generated compaction items inherit the compact request turn.
let mut compaction_item = serde_json::json!({
"type": "compaction",
"encrypted_content": summary_text,
}));
});
if let Some(turn_id) = compaction_turn_id {
compaction_item["internal_chat_message_metadata_passthrough"] =
serde_json::json!({ "turn_id": turn_id });
}
output.push(compaction_item);
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_json(serde_json::json!({ "output": output }))
+4 -1
View File
@@ -175,11 +175,14 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> {
skip_if_no_network!(Ok(()));
let call_id = "shell-command-call";
let mut shell_command_call = ev_shell_command_call(call_id, "echo websocket");
shell_command_call["item"]["internal_chat_message_metadata_passthrough"] =
serde_json::json!({"turn_id": "turn-123"});
let server = start_websocket_server(vec![vec![
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
vec![
ev_response_created("resp-1"),
ev_shell_command_call(call_id, "echo websocket"),
shell_command_call,
ev_completed("resp-1"),
],
vec![
+61 -1
View File
@@ -66,6 +66,7 @@ use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_failed;
use core_test_support::responses::strip_metadata_from_json;
use core_test_support::responses_metadata as test_responses_metadata;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
@@ -185,6 +186,65 @@ fn assert_codex_client_metadata(
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn openai_stateless_responses_requests_preserve_item_turn_metadata_across_turns() {
let server = MockServer::start().await;
let response_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp1"),
ev_assistant_message("msg-1", "first answer"),
ev_completed("resp1"),
]),
sse(vec![ev_response_created("resp2"), ev_completed("resp2")]),
],
)
.await;
let test = test_codex().build(&server).await.unwrap();
test.submit_turn("turn one").await.unwrap();
test.submit_turn("turn two").await.unwrap();
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
let first = requests[0].body_json();
let second = requests[1].body_json();
let first_turn_id = first["client_metadata"]["turn_id"]
.as_str()
.expect("first request should include turn id");
let second_turn_id = second["client_metadata"]["turn_id"]
.as_str()
.expect("second request should include turn id");
assert_ne!(first_turn_id, second_turn_id);
let first_input = first["input"].as_array().expect("first input");
let second_input = second["input"].as_array().expect("second input");
assert_eq!(&second_input[..first_input.len()], first_input.as_slice());
for item in first_input {
assert_eq!(
item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str(),
Some(first_turn_id)
);
}
let item_turn_id = |text: &str| {
second_input
.iter()
.find(|item| {
item["content"].as_array().is_some_and(|content| {
content
.iter()
.any(|content_item| content_item["text"].as_str() == Some(text))
})
})
.and_then(|item| item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str())
};
assert_eq!(item_turn_id("turn one"), Some(first_turn_id));
assert_eq!(item_turn_id("first answer"), Some(first_turn_id));
assert_eq!(item_turn_id("turn two"), Some(second_turn_id));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn non_openai_responses_requests_omit_item_passthrough_metadata() {
let server = MockServer::start().await;
@@ -3612,7 +3672,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
let tail_len = r3_tail_expected.as_array().unwrap().len();
let actual_tail = &r3_input_array[r3_input_array.len() - tail_len..];
assert_eq!(
serde_json::Value::Array(actual_tail.to_vec()),
strip_metadata_from_json(serde_json::Value::Array(actual_tail.to_vec())),
r3_tail_expected,
"request 3 tail mismatch",
);
@@ -1564,10 +1564,13 @@ async fn responses_websocket_uses_incremental_create_on_prefix() {
async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() {
skip_if_no_network!();
let mut first_output_item = ev_assistant_message("msg-1", "assistant output");
first_output_item["item"]["internal_chat_message_metadata_passthrough"] =
json!({"turn_id": "turn-123"});
let server = start_websocket_server(vec![vec![
vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "assistant output"),
first_output_item,
ev_completed("resp-1"),
],
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
@@ -1577,9 +1580,11 @@ async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_c
let harness = websocket_harness(&server).await;
let mut client_session = harness.client.new_session();
let prompt_one = prompt_with_input(vec![message_item("hello")]);
let mut prior_assistant_output = assistant_message_item("msg-1", "assistant output");
prior_assistant_output.set_turn_id_if_missing("turn-123");
let prompt_two = prompt_with_input(vec![
message_item("hello"),
assistant_message_item("msg-1", "assistant output"),
prior_assistant_output,
message_item("second"),
]);
let first_responses_metadata = turn_metadata(&harness, Some("turn-123"));
@@ -924,6 +924,7 @@ fn normalize_value(value: Value) -> Value {
Value::Array(values) => Value::Array(values.into_iter().map(normalize_value).collect()),
Value::Object(map) => Value::Object(
map.into_iter()
.filter(|(key, _value)| key != "internal_chat_message_metadata_passthrough")
.map(|(key, value)| (key, normalize_value(value)))
.collect(),
),
+2 -2
View File
@@ -181,7 +181,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
internal_chat_message_metadata_passthrough: None,
};
assert_eq!(actual, expected);
assert_eq!(responses::strip_metadata(actual), expected);
Ok(())
}
@@ -272,7 +272,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
internal_chat_message_metadata_passthrough: None,
};
assert_eq!(actual, expected);
assert_eq!(responses::strip_metadata(actual), expected);
Ok(())
}
+28 -13
View File
@@ -25,6 +25,7 @@ use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::responses::strip_metadata_from_json;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::local_selections;
@@ -54,6 +55,13 @@ fn text_user_input_parts(texts: Vec<String>) -> serde_json::Value {
})
}
fn assert_eq_without_metadata(left: serde_json::Value, right: serde_json::Value) {
assert_eq!(
strip_metadata_from_json(left),
strip_metadata_from_json(right)
);
}
fn assert_default_env_context(text: &str, cwd: &str) {
assert_env_context_fragment(text);
assert!(
@@ -384,16 +392,18 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
Some("input_text"),
"expected environment context bundled after UI message in cached contextual message"
);
assert_eq!(input1[2], text_user_input("hello 1".to_string()));
assert_eq_without_metadata(input1[2].clone(), text_user_input("hello 1".to_string()));
let body2 = req2.single_request().body_json();
let input2 = body2["input"].as_array().expect("input array");
assert_eq!(
&input2[..input1.len()],
input1.as_slice(),
"expected cached prefix to be reused"
assert_eq_without_metadata(
serde_json::Value::Array(input2[..input1.len()].to_vec()),
serde_json::Value::Array(input1.to_vec()),
);
assert_eq_without_metadata(
input2[input1.len()].clone(),
text_user_input("hello 2".to_string()),
);
assert_eq!(input2[input1.len()], text_user_input("hello 2".to_string()));
Ok(())
}
@@ -523,7 +533,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
expected_body2.push(expected_permissions_msg_2);
expected_body2.push(expected_env_msg_2);
expected_body2.push(expected_user_message_2);
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
assert_eq_without_metadata(
body2["input"].clone(),
serde_json::Value::Array(expected_body2),
);
Ok(())
}
@@ -810,7 +823,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
expected_body2.push(expected_settings_update_msg);
expected_body2.push(expected_env_msg_2);
expected_body2.push(expected_user_message_2);
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
assert_eq_without_metadata(
body2["input"].clone(),
serde_json::Value::Array(expected_body2),
);
Ok(())
}
@@ -818,7 +834,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
use pretty_assertions::assert_eq;
let server = start_mock_server().await;
let req1 = mount_sse_once(
@@ -940,7 +955,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
expected_contextual_user_msg_1.clone(),
expected_user_message_1.clone(),
]);
assert_eq!(body1["input"], expected_input_1);
assert_eq_without_metadata(body1["input"].clone(), expected_input_1);
let expected_user_message_2 = text_user_input("hello 2".to_string());
let expected_input_2 = serde_json::Value::Array(vec![
@@ -949,7 +964,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
expected_user_message_1,
expected_user_message_2,
]);
assert_eq!(body2["input"], expected_input_2);
assert_eq_without_metadata(body2["input"].clone(), expected_input_2);
Ok(())
}
@@ -1079,7 +1094,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
expected_contextual_user_msg_1.clone(),
expected_user_message_1.clone(),
]);
assert_eq!(body1["input"], expected_input_1);
assert_eq_without_metadata(body1["input"].clone(), expected_input_1);
let body1_input = body1["input"].as_array().expect("input array");
let expected_settings_update_msg = body2["input"][body1_input.len()].clone();
@@ -1118,7 +1133,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
expected_env_update_msg,
expected_user_message_2,
]);
assert_eq!(body2["input"], expected_input_2);
assert_eq_without_metadata(body2["input"].clone(), expected_input_2);
Ok(())
}
@@ -8,6 +8,7 @@ use codex_home::CodexHomeUserInstructionsProvider;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::strip_metadata;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -51,7 +52,10 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> {
phase: None,
internal_chat_message_metadata_passthrough: None,
};
assert_eq!(input.last(), Some(&expected_user_message));
assert_eq!(
input.last().cloned().map(strip_metadata),
Some(expected_user_message)
);
assert!(input.iter().any(|item| {
let ResponseItem::Message { content, .. } = item else {
return false;
@@ -28,6 +28,7 @@ use core_test_support::responses::namespace_child_tool;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::responses::start_mock_server;
use core_test_support::responses::strip_metadata_from_json;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::local_selections;
@@ -1085,8 +1086,8 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result
.pop()
.expect("child request");
assert_eq!(
child_request.inputs_of_type("agent_message"),
vec![json!({
strip_metadata_from_json(Value::Array(child_request.inputs_of_type("agent_message"))),
Value::Array(vec![json!({
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
@@ -1100,7 +1101,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result
"encrypted_content": encrypted_message,
},
],
})]
})])
);
Ok(())
@@ -1228,8 +1229,8 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message(
.pop()
.expect("agent message request");
assert_eq!(
request.inputs_of_type("agent_message"),
vec![json!({
strip_metadata_from_json(Value::Array(request.inputs_of_type("agent_message"))),
Value::Array(vec![json!({
"type": "agent_message",
"author": "/root/worker",
"recipient": "/root",
@@ -1237,7 +1238,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message(
"type": "input_text",
"text": notification,
}],
})]
})])
);
Ok(())