Support end_turn flag (#9698)

Experimental flag that signals the end of the turn.
This commit is contained in:
pakrym-oai
2026-01-22 09:27:48 -08:00
committed by GitHub
Unverified
parent 4d48d4e0c2
commit b511c38ddb
31 changed files with 92 additions and 4 deletions
@@ -307,6 +307,7 @@ async fn test_list_and_resume_conversations() -> Result<()> {
content: vec![ContentItem::InputText {
text: fork_history_text.to_string(),
}],
end_turn: None,
}];
let resume_with_history_req_id = mcp
.send_resume_conversation_request(ResumeConversationParams {
@@ -218,6 +218,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> {
content: vec![ContentItem::InputText {
text: history_text.to_string(),
}],
end_turn: None,
}];
// Resume with explicit history and override the model.
+1
View File
@@ -193,6 +193,7 @@ impl Stream for AggregatedStream {
content: vec![ContentItem::OutputText {
text: std::mem::take(&mut this.cumulative),
}],
end_turn: None,
};
this.pending
.push_back(ResponseEvent::OutputItemDone(aggregated_message));
+2
View File
@@ -386,6 +386,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "hi".to_string(),
}],
end_turn: None,
}];
let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[])
.conversation_id(Some("conv-1".into()))
@@ -412,6 +413,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "read these".to_string(),
}],
end_turn: None,
},
ResponseItem::FunctionCall {
id: None,
@@ -223,11 +223,13 @@ mod tests {
id: Some("m1".into()),
role: "assistant".into(),
content: Vec::new(),
end_turn: None,
},
ResponseItem::Message {
id: None,
role: "assistant".into(),
content: Vec::new(),
end_turn: None,
},
];
+1
View File
@@ -330,6 +330,7 @@ async fn append_assistant_text(
id: None,
role: "assistant".to_string(),
content: vec![],
end_turn: None,
};
*assistant_item = Some(item.clone());
let _ = tx_event
+1
View File
@@ -308,6 +308,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> {
content: vec![ContentItem::InputText {
text: "hi".to_string(),
}],
end_turn: None,
}],
tools: Vec::<Value>::new(),
parallel_tool_calls: false,
-1
View File
@@ -1 +0,0 @@
danger-unless-trusted
+12
View File
@@ -1504,6 +1504,7 @@ impl Session {
content: vec![ContentItem::InputText {
text: format!("Warning: {}", message.into()),
}],
end_turn: None,
};
self.record_conversation_items(ctx, &[item]).await;
@@ -3613,6 +3614,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "turn 1 user".to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -3620,6 +3622,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: "turn 1 assistant".to_string(),
}],
end_turn: None,
},
];
sess.record_into_history(&turn_1, tc.as_ref()).await;
@@ -3631,6 +3634,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "turn 2 user".to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -3638,6 +3642,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: "turn 2 assistant".to_string(),
}],
end_turn: None,
},
];
sess.record_into_history(&turn_2, tc.as_ref()).await;
@@ -3669,6 +3674,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "turn 1 user".to_string(),
}],
end_turn: None,
}];
sess.record_into_history(&turn_1, tc.as_ref()).await;
@@ -4561,6 +4567,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "first user".to_string(),
}],
end_turn: None,
};
live_history.record_items(std::iter::once(&user1), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(user1.clone()));
@@ -4571,6 +4578,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: "assistant reply one".to_string(),
}],
end_turn: None,
};
live_history.record_items(std::iter::once(&assistant1), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(assistant1.clone()));
@@ -4595,6 +4603,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "second user".to_string(),
}],
end_turn: None,
};
live_history.record_items(std::iter::once(&user2), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(user2.clone()));
@@ -4605,6 +4614,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: "assistant reply two".to_string(),
}],
end_turn: None,
};
live_history.record_items(std::iter::once(&assistant2), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(assistant2.clone()));
@@ -4629,6 +4639,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "third user".to_string(),
}],
end_turn: None,
};
live_history.record_items(std::iter::once(&user3), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(user3));
@@ -4639,6 +4650,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: "assistant reply three".to_string(),
}],
end_turn: None,
};
live_history.record_items(std::iter::once(&assistant3), turn_context.truncation_policy);
rollout_items.push(RolloutItem::ResponseItem(assistant3));
+9
View File
@@ -305,6 +305,7 @@ fn build_compacted_history_with_limit(
content: vec![ContentItem::InputText {
text: message.clone(),
}],
end_turn: None,
});
}
@@ -318,6 +319,7 @@ fn build_compacted_history_with_limit(
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: summary_text }],
end_turn: None,
});
history
@@ -406,6 +408,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: "ignored".to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: Some("user".to_string()),
@@ -413,6 +416,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "first".to_string(),
}],
end_turn: None,
},
ResponseItem::Other,
];
@@ -432,6 +436,7 @@ mod tests {
text: "# AGENTS.md instructions for project\n\n<INSTRUCTIONS>\ndo things\n</INSTRUCTIONS>"
.to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -439,6 +444,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "<ENVIRONMENT_CONTEXT>cwd=/tmp</ENVIRONMENT_CONTEXT>".to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -446,6 +452,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "real user message".to_string(),
}],
end_turn: None,
},
];
@@ -530,6 +537,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: marker.clone(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -537,6 +545,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "real user message".to_string(),
}],
end_turn: None,
},
];
@@ -23,6 +23,7 @@ fn assistant_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
}
}
@@ -41,6 +42,7 @@ fn user_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
}
}
@@ -51,6 +53,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
}
}
@@ -93,6 +96,7 @@ fn filters_non_api_messages() {
content: vec![ContentItem::OutputText {
text: "ignored".to_string(),
}],
end_turn: None,
};
let reasoning = reasoning_msg("thinking...");
h.record_items([&system, &reasoning, &ResponseItem::Other], policy);
@@ -121,14 +125,16 @@ fn filters_non_api_messages() {
role: "user".to_string(),
content: vec![ContentItem::OutputText {
text: "hi".to_string()
}]
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "hello".to_string()
}]
}],
end_turn: None,
}
]
);
@@ -255,6 +261,7 @@ fn replace_last_turn_images_does_not_touch_user_images() {
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
}],
end_turn: None,
}];
let mut history = create_history_with_items(items.clone());
+1
View File
@@ -79,6 +79,7 @@ impl From<EnvironmentContext> for ResponseItem {
content: vec![ContentItem::InputText {
text: ec.serialize_to_xml(),
}],
end_turn: None,
}
}
}
+12 -1
View File
@@ -89,7 +89,9 @@ fn parse_agent_message(id: Option<&String>, message: &[ContentItem]) -> AgentMes
pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
match item {
ResponseItem::Message { role, content, id } => match role.as_str() {
ResponseItem::Message {
role, content, id, ..
} => match role.as_str() {
"user" => parse_user_message(content).map(TurnItem::UserMessage),
"assistant" => Some(TurnItem::AgentMessage(parse_agent_message(
id.as_ref(),
@@ -169,6 +171,7 @@ mod tests {
image_url: img2.clone(),
},
],
end_turn: None,
};
let turn_item = parse_turn_item(&item).expect("expected user message turn item");
@@ -210,6 +213,7 @@ mod tests {
text: user_text.clone(),
},
],
end_turn: None,
};
let turn_item = parse_turn_item(&item).expect("expected user message turn item");
@@ -250,6 +254,7 @@ mod tests {
text: user_text.clone(),
},
],
end_turn: None,
};
let turn_item = parse_turn_item(&item).expect("expected user message turn item");
@@ -278,6 +283,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "<user_instructions>test_text</user_instructions>".to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -285,6 +291,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "<environment_context>test_text</environment_context>".to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -292,6 +299,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -300,6 +308,7 @@ mod tests {
text: "<skill>\n<name>demo</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
.to_string(),
}],
end_turn: None,
},
ResponseItem::Message {
id: None,
@@ -307,6 +316,7 @@ mod tests {
content: vec![ContentItem::InputText {
text: "<user_shell_command>echo 42</user_shell_command>".to_string(),
}],
end_turn: None,
},
];
@@ -324,6 +334,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: "Hello from Codex".to_string(),
}],
end_turn: None,
};
let turn_item = parse_turn_item(&item).expect("expected agent message turn item");
@@ -38,6 +38,7 @@ impl From<UserInstructions> for ResponseItem {
contents = ui.text
),
}],
end_turn: None,
}
}
}
@@ -71,6 +72,7 @@ impl From<SkillInstructions> for ResponseItem {
si.name, si.path, si.contents
),
}],
end_turn: None,
}
}
}
+1
View File
@@ -806,6 +806,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
content: vec![ContentItem::OutputText {
text: format!("reply-{idx}"),
}],
end_turn: None,
}),
};
writeln!(file, "{}", serde_json::to_string(&response_line)?)?;
+2
View File
@@ -85,6 +85,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
}
}
@@ -95,6 +96,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
}
}
+1
View File
@@ -256,6 +256,7 @@ impl Session {
"{TURN_ABORTED_OPEN_TAG}\n <turn_id>{sub_id}</turn_id>\n <reason>interrupted</reason>\n <guidance>{TURN_ABORTED_INTERRUPTED_GUIDANCE}</guidance>\n</turn_aborted>"
),
}],
end_turn: None,
};
self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref())
.await;
+2
View File
@@ -221,6 +221,7 @@ pub(crate) async fn exit_review_mode(
id: Some(REVIEW_USER_MESSAGE_ID.to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText { text: user_message }],
end_turn: None,
}],
)
.await;
@@ -239,6 +240,7 @@ pub(crate) async fn exit_review_mode(
content: vec![ContentItem::OutputText {
text: assistant_message,
}],
end_turn: None,
},
)
.await;
+2
View File
@@ -411,6 +411,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
}
}
fn assistant_msg(text: &str) -> ResponseItem {
@@ -420,6 +421,7 @@ mod tests {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
}
}
+1
View File
@@ -62,6 +62,7 @@ pub fn user_shell_command_record_item(
content: vec![ContentItem::InputText {
text: format_user_shell_command_record(command, exec_output, turn_context),
}],
end_turn: None,
}
}
@@ -135,6 +135,7 @@ fn user_message(text: &str) -> ResponseItem {
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
end_turn: None,
}
}
@@ -145,6 +146,7 @@ fn assistant_message(text: &str) -> ResponseItem {
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: None,
}
}
@@ -109,6 +109,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec<ResponseEvent> {
content: vec![ContentItem::InputText {
text: "hello".to_string(),
}],
end_turn: None,
}];
let mut stream = match client.stream(&prompt).await {
+3
View File
@@ -104,6 +104,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
content: vec![ContentItem::InputText {
text: "hello".into(),
}],
end_turn: None,
}];
let mut stream = client_session.stream(&prompt).await.expect("stream failed");
@@ -200,6 +201,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
content: vec![ContentItem::InputText {
text: "hello".into(),
}],
end_turn: None,
}];
let mut stream = client_session.stream(&prompt).await.expect("stream failed");
@@ -354,6 +356,7 @@ async fn responses_respects_model_info_overrides_from_config() {
content: vec![ContentItem::InputText {
text: "hello".into(),
}],
end_turn: None,
}];
let mut stream = client.stream(&prompt).await.expect("stream failed");
+4
View File
@@ -194,6 +194,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
content: vec![codex_protocol::models::ContentItem::InputText {
text: "resumed user message".to_string(),
}],
end_turn: None,
};
let prior_user_json = serde_json::to_value(&prior_user).unwrap();
writeln!(
@@ -214,6 +215,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "resumed system instruction".to_string(),
}],
end_turn: None,
};
let prior_system_json = serde_json::to_value(&prior_system).unwrap();
writeln!(
@@ -234,6 +236,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "resumed assistant message".to_string(),
}],
end_turn: None,
};
let prior_item_json = serde_json::to_value(&prior_item).unwrap();
writeln!(
@@ -1275,6 +1278,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
content: vec![ContentItem::OutputText {
text: "message".into(),
}],
end_turn: None,
});
prompt.input.push(ResponseItem::WebSearchCall {
id: Some("web-search-id".into()),
@@ -170,6 +170,7 @@ fn message_item(text: &str) -> ResponseItem {
id: None,
role: "user".into(),
content: vec![ContentItem::InputText { text: text.into() }],
end_turn: None,
}
}
+3
View File
@@ -1221,6 +1221,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: remote_summary.to_string(),
}],
end_turn: None,
},
codex_protocol::models::ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
@@ -2071,6 +2072,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "REMOTE_COMPACT_SUMMARY".to_string(),
}],
end_turn: None,
},
codex_protocol::models::ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
@@ -2190,6 +2192,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "REMOTE_COMPACT_SUMMARY".to_string(),
}],
end_turn: None,
},
codex_protocol::models::ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
@@ -58,6 +58,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
content: vec![ContentItem::InputText {
text: "REMOTE_COMPACTED_SUMMARY".to_string(),
}],
end_turn: None,
},
ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
@@ -179,6 +180,7 @@ async fn remote_compact_runs_automatically() -> Result<()> {
content: vec![ContentItem::InputText {
text: "REMOTE_COMPACTED_SUMMARY".to_string(),
}],
end_turn: None,
},
ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
@@ -246,6 +248,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
content: vec![ContentItem::InputText {
text: "COMPACTED_USER_SUMMARY".to_string(),
}],
end_turn: None,
},
ResponseItem::Compaction {
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
@@ -256,6 +259,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
content: vec![ContentItem::OutputText {
text: "COMPACTED_ASSISTANT_NOTE".to_string(),
}],
end_turn: None,
},
];
let compact_mock = responses::mount_compact_json_once(
@@ -156,6 +156,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
text: "pasted image".to_string(),
},
],
end_turn: None,
};
assert_eq!(actual, expected);
@@ -235,6 +236,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
text: "dropped image".to_string(),
},
],
end_turn: None,
};
assert_eq!(actual, expected);
+2
View File
@@ -532,6 +532,7 @@ async fn review_input_isolated_from_parent_history() {
content: vec![codex_protocol::models::ContentItem::InputText {
text: "parent: earlier user message".to_string(),
}],
end_turn: None,
};
let user_json = serde_json::to_value(&user).unwrap();
let user_line = serde_json::json!({
@@ -550,6 +551,7 @@ async fn review_input_isolated_from_parent_history() {
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "parent: assistant reply".to_string(),
}],
end_turn: None,
};
let assistant_json = serde_json::to_value(&assistant).unwrap();
let assistant_line = serde_json::json!({
+6
View File
@@ -80,6 +80,10 @@ pub enum ResponseItem {
id: Option<String>,
role: String,
content: Vec<ContentItem>,
// Do not use directly, no available consistently across all providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
end_turn: Option<bool>,
},
Reasoning {
#[serde(default, skip_serializing)]
@@ -328,6 +332,7 @@ impl From<DeveloperInstructions> for ResponseItem {
content: vec![ContentItem::InputText {
text: di.into_text(),
}],
end_turn: None,
}
}
}
@@ -497,6 +502,7 @@ impl From<ResponseInputItem> for ResponseItem {
role,
content,
id: None,
end_turn: None,
},
ResponseInputItem::FunctionCallOutput { call_id, output } => {
Self::FunctionCallOutput { call_id, output }
+1
View File
@@ -1607,6 +1607,7 @@ impl From<CompactedItem> for ResponseItem {
content: vec![ContentItem::OutputText {
text: value.message,
}],
end_turn: None,
}
}
}