Skip to content

Commit d2885dc

Browse files
authored
core: stabilize synthesized call output IDs (openai#30327)
## Why Response item IDs represent stable conversation identity. `ContextManager::for_prompt` repairs an unmatched call by synthesizing an `"aborted"` output in the disposable prompt projection, but that output previously had no ID. Assigning a fresh ID on every prompt build would make retries and resumes change otherwise identical model context and reduce prompt-cache reuse. The concrete bug is that these normalization-created outputs bypass the regular item-ID allocation path. Even with item IDs enabled, a prompt could therefore contain an identified call paired with a synthetic output whose `id` was missing. This change closes that gap by deriving the output ID from the source call's item ID. For legacy calls that have no item ID, the output remains ID-less because there is no stable source identity to derive from. The originating call already has a stable item ID under the item-ID model introduced in openai#28814. A prompt-only output can therefore derive stable identity from that call without mutating canonical history or persisted rollouts. This addresses the failure exposed by openai#30311 while keeping normalization read-only outside its detached prompt snapshot. UUIDv5 is intentional here because it is the standard namespaced, deterministic UUID construction. Using the output kind and source call ID as the name produces the same UUID on every projection while keeping output kinds in separate name domains. UUIDv7 would introduce randomness and time, so keeping it stable would require persisting the synthetic repair. UUIDv5 uses SHA-1 internally, but this is only an identity mapping—not an authenticity or security boundary. ## What changed - Derive a deterministic UUIDv5 ID for each synthesized call output from the source call item ID. - Use the Responses API prefix appropriate for function, custom-tool, tool-search, and local-shell outputs. - Preserve the existing insertion position immediately after the unmatched call. - Keep synthesized outputs prompt-only; no rollout, task-lifecycle, compaction, or raw-response behavior changes. ## Testing - `just test -p codex-core for_prompt_assigns_stable_id_to_synthetic_output_without_reordering_history` - `just test -p codex-core synthetic_call_output_id_is_stable_across_resumes` - `just test -p codex-core normalize_adds_missing_output` - `just test -p codex-core response_item_ids`
1 parent 328e951 commit d2885dc

3 files changed

Lines changed: 170 additions & 6 deletions

File tree

codex-rs/core/src/context_manager/history_tests.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,6 +1629,49 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
16291629
);
16301630
}
16311631

1632+
#[test]
1633+
fn for_prompt_assigns_stable_id_to_synthetic_output_without_reordering_history() {
1634+
let items = vec![
1635+
ResponseItem::FunctionCall {
1636+
id: Some("fc_existing".to_string()),
1637+
name: "do_it".to_string(),
1638+
namespace: None,
1639+
arguments: "{}".to_string(),
1640+
call_id: "call-x".to_string(),
1641+
internal_chat_message_metadata_passthrough: None,
1642+
},
1643+
ResponseItem::Message {
1644+
id: Some("msg_later".to_string()),
1645+
role: "user".to_string(),
1646+
content: vec![ContentItem::InputText {
1647+
text: "later turn".to_string(),
1648+
}],
1649+
phase: None,
1650+
internal_chat_message_metadata_passthrough: None,
1651+
},
1652+
];
1653+
1654+
let first = create_history_with_items(items.clone()).for_prompt(&default_input_modalities());
1655+
let second = create_history_with_items(items).for_prompt(&default_input_modalities());
1656+
1657+
assert_eq!(
1658+
first, second,
1659+
"repeated prompt projections should assign the same ID to the synthetic output"
1660+
);
1661+
let [
1662+
ResponseItem::FunctionCall { .. },
1663+
ResponseItem::FunctionCallOutput { id: Some(id), .. },
1664+
ResponseItem::Message { .. },
1665+
] = first.as_slice()
1666+
else {
1667+
panic!("expected the synthetic output between its call and the later message");
1668+
};
1669+
assert!(
1670+
id.starts_with("fco_"),
1671+
"the synthetic function call output should use the Responses API output ID prefix"
1672+
);
1673+
}
1674+
16321675
#[test]
16331676
fn normalize_adds_missing_output_for_tool_search_call() {
16341677
let items = vec![ResponseItem::ToolSearchCall {

codex-rs/core/src/context_manager/normalize.rs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ use codex_protocol::models::FunctionCallOutputPayload;
44
use codex_protocol::models::ResponseItem;
55
use codex_protocol::openai_models::InputModality;
66
use std::collections::HashSet;
7+
use uuid::Uuid;
78

89
use crate::util::error_or_panic;
910
use tracing::info;
1011

1112
const IMAGE_CONTENT_OMITTED_PLACEHOLDER: &str =
1213
"image content omitted because you do not support image input";
14+
// Changing this value would change model-visible IDs and invalidate prompt caches.
15+
const SYNTHETIC_OUTPUT_ID_NAMESPACE: Uuid = Uuid::from_u128(0x90d38d3e_6a5b_4d52_bfe2_2f1e634bfac4);
1316

1417
pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
1518
let mut function_output_ids = HashSet::new();
@@ -40,29 +43,30 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
4043

4144
for (idx, item) in items.iter().enumerate() {
4245
match item {
43-
ResponseItem::FunctionCall { call_id, .. }
46+
ResponseItem::FunctionCall { id, call_id, .. }
4447
if !function_output_ids.contains(call_id.as_str()) =>
4548
{
4649
info!("Function call output is missing for call id: {call_id}");
4750
missing_outputs_to_insert.push((
4851
idx,
4952
ResponseItem::FunctionCallOutput {
50-
id: None,
53+
id: synthetic_output_id("fco", id.as_deref()),
5154
call_id: call_id.clone(),
5255
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
5356
internal_chat_message_metadata_passthrough: None,
5457
},
5558
));
5659
}
5760
ResponseItem::ToolSearchCall {
61+
id,
5862
call_id: Some(call_id),
5963
..
6064
} if !tool_search_output_ids.contains(call_id.as_str()) => {
6165
info!("Tool search output is missing for call id: {call_id}");
6266
missing_outputs_to_insert.push((
6367
idx,
6468
ResponseItem::ToolSearchOutput {
65-
id: None,
69+
id: synthetic_output_id("tso", id.as_deref()),
6670
call_id: Some(call_id.clone()),
6771
status: "completed".to_string(),
6872
execution: "client".to_string(),
@@ -71,7 +75,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
7175
},
7276
));
7377
}
74-
ResponseItem::CustomToolCall { call_id, .. }
78+
ResponseItem::CustomToolCall { id, call_id, .. }
7579
if !custom_tool_output_ids.contains(call_id.as_str()) =>
7680
{
7781
error_or_panic(format!(
@@ -80,7 +84,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
8084
missing_outputs_to_insert.push((
8185
idx,
8286
ResponseItem::CustomToolCallOutput {
83-
id: None,
87+
id: synthetic_output_id("ctco", id.as_deref()),
8488
call_id: call_id.clone(),
8589
name: None,
8690
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
@@ -90,6 +94,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
9094
}
9195
// LocalShellCall is represented in upstream streams by a FunctionCallOutput
9296
ResponseItem::LocalShellCall {
97+
id,
9398
call_id: Some(call_id),
9499
..
95100
} if !function_output_ids.contains(call_id.as_str()) => {
@@ -99,7 +104,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
99104
missing_outputs_to_insert.push((
100105
idx,
101106
ResponseItem::FunctionCallOutput {
102-
id: None,
107+
id: synthetic_output_id("fco", id.as_deref()),
103108
call_id: call_id.clone(),
104109
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
105110
internal_chat_message_metadata_passthrough: None,
@@ -121,6 +126,21 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
121126
}
122127
}
123128

129+
/// Derives a stable ID for a prompt-only output from its source call's item ID.
130+
///
131+
/// Prompt normalization can run repeatedly without persisting its synthetic
132+
/// outputs, so the namespace and name format must remain stable across retries
133+
/// and resumes to preserve prompt-cache reuse. Returning `None` when the source
134+
/// call has no ID preserves the legacy behavior for older history items.
135+
fn synthetic_output_id(prefix: &str, item_id: Option<&str>) -> Option<String> {
136+
let source_id = item_id.filter(|id| !id.is_empty())?;
137+
let name = format!("{prefix}:{source_id}");
138+
Some(format!(
139+
"{prefix}_{}",
140+
Uuid::new_v5(&SYNTHETIC_OUTPUT_ID_NAMESPACE, name.as_bytes())
141+
))
142+
}
143+
124144
pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
125145
let function_call_ids: HashSet<String> = items
126146
.iter()

codex-rs/core/tests/suite/client.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,107 @@ async fn response_item_ids_persist_across_resume_and_preserve_server_ids() -> an
363363
Ok(())
364364
}
365365

366+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
367+
async fn synthetic_call_output_id_is_stable_across_resumes() -> anyhow::Result<()> {
368+
let function_call_id = "missing-output-call";
369+
let thread_id = ThreadId::default();
370+
let rollout = vec![
371+
RolloutLine {
372+
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
373+
item: RolloutItem::SessionMeta(SessionMetaLine {
374+
meta: SessionMeta {
375+
session_id: thread_id.into(),
376+
id: thread_id,
377+
parent_thread_id: None,
378+
timestamp: "2024-01-01T00:00:00Z".to_string(),
379+
cwd: ".".into(),
380+
originator: "test_originator".to_string(),
381+
cli_version: "test_version".to_string(),
382+
model_provider: Some("test-provider".to_string()),
383+
..Default::default()
384+
},
385+
git: None,
386+
}),
387+
},
388+
RolloutLine {
389+
timestamp: "2024-01-01T00:00:01.000Z".to_string(),
390+
item: RolloutItem::ResponseItem(ResponseItem::FunctionCall {
391+
id: Some("fc_existing".to_string()),
392+
name: "do_it".to_string(),
393+
namespace: None,
394+
arguments: "{}".to_string(),
395+
call_id: function_call_id.to_string(),
396+
internal_chat_message_metadata_passthrough: None,
397+
}),
398+
},
399+
];
400+
let tmpdir = TempDir::new()?;
401+
let session_path = tmpdir.path().join("normalized-call-output-item-id.jsonl");
402+
let mut file = std::fs::File::create(&session_path)?;
403+
for line in rollout {
404+
writeln!(file, "{}", serde_json::to_string(&line)?)?;
405+
}
406+
407+
let server = MockServer::start().await;
408+
let response_mock = mount_sse_sequence(
409+
&server,
410+
vec![
411+
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
412+
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
413+
],
414+
)
415+
.await;
416+
let codex_home = Arc::new(TempDir::new()?);
417+
let mut builder = test_codex().with_config(|config| {
418+
let _ = config.features.enable(Feature::ItemIds);
419+
});
420+
let first = builder
421+
.resume(&server, Arc::clone(&codex_home), session_path.clone())
422+
.await?;
423+
424+
first.submit_turn("first resume").await?;
425+
first.codex.submit(Op::Shutdown).await?;
426+
wait_for_event(&first.codex, |event| {
427+
matches!(event, EventMsg::ShutdownComplete)
428+
})
429+
.await;
430+
assert!(
431+
!std::fs::read_to_string(&session_path)?.contains("\"type\":\"function_call_output\""),
432+
"prompt-only repair should not be persisted to the rollout"
433+
);
434+
435+
builder = builder.with_config(|config| {
436+
let _ = config.features.enable(Feature::ItemIds);
437+
});
438+
let second = builder.resume(&server, codex_home, session_path).await?;
439+
second.submit_turn("second resume").await?;
440+
441+
let requests = response_mock.requests();
442+
assert_eq!(requests.len(), 2);
443+
let first_output = requests[0].function_call_output(function_call_id);
444+
let first_output_id = first_output
445+
.get("id")
446+
.and_then(serde_json::Value::as_str)
447+
.expect("reconstructed output should have an item ID")
448+
.to_string();
449+
let first_output_uuid = first_output_id
450+
.strip_prefix("fco_")
451+
.expect("synthetic output should use the Responses API prefix");
452+
assert_eq!(
453+
Uuid::parse_str(first_output_uuid)?.get_version(),
454+
Some(uuid::Version::Sha1)
455+
);
456+
assert_eq!(
457+
requests[1]
458+
.function_call_output(function_call_id)
459+
.get("id")
460+
.and_then(serde_json::Value::as_str),
461+
Some(first_output_id.as_str())
462+
);
463+
464+
Ok(())
465+
}
466+
366467
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
367468
async fn response_item_ids_are_sent_for_all_remote_v2_compaction_requests() -> anyhow::Result<()> {
368469
let server = MockServer::start().await;

0 commit comments

Comments
 (0)