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()
}]
);
}
}
+10
View File
@@ -156,6 +156,16 @@ pub enum Event {
AssistantText {
delta: String,
},
/// The authoritative text of the assistant message whose deltas immediately
/// precede this event.
///
/// Some providers stream a provisional rendering and revise it before the
/// item completes. This stays append-only like every other transcript
/// correction: readers replace the open message rather than editing an old
/// line, and replay therefore reaches the same text as the live stream.
AssistantTextFinal {
text: String,
},
ToolStart {
id: String,
tool: String,
+3
View File
@@ -497,6 +497,9 @@ fn conversation(path: &Path) -> Vec<Message> {
});
}
Event::AssistantText { delta } => pending.push_str(&delta),
Event::AssistantTextFinal { text } => {
pending = text;
}
_ => {}
}
}
+3
View File
@@ -834,6 +834,9 @@ mod tests {
attachments: Vec::new(),
},
text("hello"),
Event::AssistantTextFinal {
text: "hello, revised".into(),
},
Event::ToolStart {
id: "t1".into(),
tool: "bash".into(),