Fix Codex transcript convergence

This commit is contained in:
iris committed 2026-09-10 01:24:16 -04:00
1 parent f9c8f640ce
commit 3c19b5a9bb
9 files changed
+192 -48

No files matched your search

+35 -19
View File
@@ -52,12 +52,11 @@ impl Translator {
Some("item.started") | Some("item/started") => start_item(&body["item"]),
Some("item.updated") => update_item(&line["item"]),
// The old `codex exec --json` dialect reports only the completed message. App-server
// reports every message through durable delta notifications and its completed copy
// must always be skipped. That rule cannot live in an in-memory set: after a backend
// restart the deltas are behind the persisted log cursor while the completion is not,
// which used to append the whole message again after its already-recorded prefix.
// also reports deltas, but they are provisional: safety buffering can revise their
// text before completion. Keep its completed copy as an append-only correction rather
// than guessing that the two representations concatenate to the same answer.
Some("item.completed") => complete_item(&body["item"], true),
Some("item/completed") => complete_item(&body["item"], false),
Some("item/completed") => final_item(&body["item"]),
Some("item/agentMessage/delta") => {
let Some(delta) = body.get("delta").and_then(Value::as_str) else {
return Vec::new();
@@ -221,6 +220,21 @@ fn complete_item(item: &Value, include_agent_message: bool) -> Vec<Event> {
}
}
fn final_item(item: &Value) -> Vec<Event> {
match item.get("type").and_then(Value::as_str) {
Some("agentMessage") => item
.get("text")
.and_then(Value::as_str)
.map(|text| {
vec![Event::AssistantTextFinal {
text: text.to_string(),
}]
})
.unwrap_or_default(),
_ => complete_item(item, false),
}
}
fn tool(item: &Value) -> Option<(String, String, Value)> {
let id = item.get("id")?.as_str()?.to_string();
let kind = item.get("type")?.as_str()?;
@@ -633,7 +647,7 @@ mod tests {
}
#[test]
fn translates_native_app_server_streaming_without_repeating_the_final_item() {
fn native_app_server_completion_corrects_provisional_streaming() {
let mut translator = Translator::default();
assert_eq!(
translator.translate(&line(
@@ -651,12 +665,13 @@ mod tests {
delta: "hello".to_string()
}]
);
assert!(
translator
.translate(&line(
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"hello"}}}"#
))
.is_empty()
assert_eq!(
translator.translate(&line(
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"hello, revised"}}}"#
)),
vec![Event::AssistantTextFinal {
text: "hello, revised".to_string()
}]
);
assert!(
translator
@@ -702,16 +717,17 @@ mod tests {
}
#[test]
fn an_app_server_completion_never_repeats_streamed_text_after_adoption() {
fn an_app_server_completion_corrects_streamed_text_after_adoption() {
// A newly adopted translator has not seen the deltas already recorded by the previous
// backend. The dialect, rather than process-local memory, decides that this is a copy.
let mut adopted = Translator::default();
assert!(
adopted
.translate(&line(
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"the complete message"}}}"#
))
.is_empty()
assert_eq!(
adopted.translate(&line(
r#"{"method":"item/completed","params":{"item":{"id":"message-1","type":"agentMessage","text":"the complete message"}}}"#
)),
vec![Event::AssistantTextFinal {
text: "the complete message".to_string()
}]
);
}
}