use serde_json::Value;
use crate::agent;
use crate::agent::protocol::parse_agent_response_strict;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ExtractedAgentMessage {
pub(super) message: String,
pub(super) phase: Option<String>,
}
pub(super) fn preferred_completed_assistant_message(assistant_messages: &[String]) -> String {
if let Some(protocol_with_summary) = assistant_messages.iter().rev().find_map(|message| {
let trimmed_message = message.trim();
if trimmed_message.is_empty() {
return None;
}
let response = parse_agent_response_strict(trimmed_message).ok()?;
response.summary.as_ref()?;
Some(trimmed_message.to_string())
}) {
return protocol_with_summary;
}
if let Some(protocol_payload) = assistant_messages.iter().rev().find_map(|message| {
let trimmed_message = message.trim();
if trimmed_message.is_empty() {
return None;
}
let response = parse_agent_response_strict(trimmed_message).ok()?;
if response.answer.trim().is_empty()
&& response.questions.is_empty()
&& response.summary.is_none()
{
return None;
}
Some(trimmed_message.to_string())
}) {
return protocol_payload;
}
assistant_messages
.iter()
.rev()
.find_map(|message| {
let trimmed_message = message.trim();
if trimmed_message.is_empty() {
return None;
}
Some(trimmed_message.to_string())
})
.unwrap_or_default()
}
pub(super) fn extract_turn_id_from_turn_start_response(response_value: &Value) -> Option<String> {
let result = response_value.get("result")?;
result
.get("turn")
.and_then(|turn| turn.get("id"))
.and_then(Value::as_str)
.map(ToString::to_string)
.or_else(|| {
result
.get("turn")
.and_then(|turn| turn.get("turnId"))
.and_then(Value::as_str)
.map(ToString::to_string)
})
.or_else(|| {
result
.get("turn")
.and_then(|turn| turn.get("turn_id"))
.and_then(Value::as_str)
.map(ToString::to_string)
})
.or_else(|| {
result
.get("turnId")
.and_then(Value::as_str)
.map(ToString::to_string)
})
.or_else(|| {
result
.get("turn_id")
.and_then(Value::as_str)
.map(ToString::to_string)
})
}
pub(super) fn extract_turn_id_from_turn_started_notification(
response_value: &Value,
) -> Option<String> {
if response_value.get("method").and_then(Value::as_str) != Some("turn/started") {
return None;
}
let params = response_value.get("params")?;
params
.get("turn")
.and_then(|turn| turn.get("id"))
.and_then(Value::as_str)
.map(ToString::to_string)
.or_else(|| {
params
.get("turn")
.and_then(|turn| turn.get("turnId"))
.and_then(Value::as_str)
.map(ToString::to_string)
})
.or_else(|| {
params
.get("turn")
.and_then(|turn| turn.get("turn_id"))
.and_then(Value::as_str)
.map(ToString::to_string)
})
.or_else(|| {
params
.get("turnId")
.and_then(Value::as_str)
.map(ToString::to_string)
})
.or_else(|| {
params
.get("turn_id")
.and_then(Value::as_str)
.map(ToString::to_string)
})
}
pub(super) fn extract_agent_message_delta(response_value: &Value) -> Option<ExtractedAgentMessage> {
let method = response_value.get("method").and_then(Value::as_str)?;
if matches!(
method,
"item/plan/delta"
| "item/reasoning/textDelta"
| "item/reasoning/text_delta"
| "item/reasoning/summaryTextDelta"
| "item/reasoning/summary_text_delta"
) {
let delta = response_value
.get("params")?
.get("delta")
.and_then(Value::as_str)?;
if delta.trim().is_empty() {
return None;
}
let phase = if method == "item/plan/delta" {
Some("plan".to_string())
} else {
Some("thinking".to_string())
};
return Some(ExtractedAgentMessage {
message: delta.to_string(),
phase,
});
}
if method != "item/updated" {
return None;
}
let item = response_value.get("params")?.get("item")?;
let phase = item
.get("phase")
.and_then(Value::as_str)
.map(ToString::to_string);
let item_type = item.get("type")?.as_str()?.to_ascii_lowercase();
if item_type != "reasoning" && item_type != "thought" {
return None;
}
let delta = item.get("delta").and_then(Value::as_str)?;
if delta.trim().is_empty() {
return None;
}
Some(ExtractedAgentMessage {
message: delta.to_string(),
phase: phase.or(Some("thinking".to_string())),
})
}
pub(super) fn extract_agent_message(response_value: &Value) -> Option<ExtractedAgentMessage> {
if response_value.get("method").and_then(Value::as_str) != Some("item/completed") {
return None;
}
let item = response_value.get("params")?.get("item")?;
let phase = item
.get("phase")
.and_then(Value::as_str)
.map(ToString::to_string);
let item_type = item.get("type")?.as_str()?.to_ascii_lowercase();
if !is_completed_assistant_message_item_type(&item_type) {
return None;
}
if is_codex_thought_phase(phase.as_deref()) {
return None;
}
if let Some(item_text) = item.get("text").and_then(Value::as_str) {
if agent::is_codex_completion_status_message(item_text) {
return None;
}
return Some(ExtractedAgentMessage {
message: item_text.to_string(),
phase,
});
}
let content = item.get("content")?.as_array()?;
let mut parts = Vec::new();
for content_item in content {
if let Some(text) = content_item.get("text").and_then(Value::as_str) {
parts.push(text.to_string());
}
}
if parts.is_empty() {
return None;
}
let message = parts.join("\n\n");
if agent::is_codex_completion_status_message(&message) {
return None;
}
Some(ExtractedAgentMessage { message, phase })
}
pub(super) fn is_completed_assistant_message_item_type(item_type: &str) -> bool {
matches!(
item_type,
"agentmessage" | "agent_message" | "assistantmessage" | "assistant_message"
)
}
pub(super) fn is_codex_thought_phase(phase: Option<&str>) -> bool {
let Some(phase_value) = phase else {
return false;
};
let normalized_phase = phase_value.trim();
normalized_phase.eq_ignore_ascii_case("thinking")
|| normalized_phase.eq_ignore_ascii_case("plan")
|| normalized_phase.eq_ignore_ascii_case("reasoning")
|| normalized_phase.eq_ignore_ascii_case("thought")
}
pub(super) fn parse_turn_completed(
response_value: &Value,
expected_turn_id: Option<&str>,
) -> Option<Result<(), String>> {
if response_value.get("method").and_then(Value::as_str) != Some("turn/completed") {
return None;
}
let expected_turn_id = expected_turn_id?;
let turn_id = extract_turn_id_from_turn_completed_notification(response_value)?;
if turn_id != expected_turn_id {
return None;
}
let status = response_value
.get("params")
.and_then(|params| params.get("turn"))
.and_then(|turn| turn.get("status"))
.and_then(Value::as_str)
.unwrap_or_default();
match status {
"completed" => Some(Ok(())),
"failed" => Some(Err(extract_turn_completed_error_message(response_value)
.unwrap_or_else(|| "Codex app-server turn failed".to_string()))),
"" => Some(Err("Codex app-server `turn/completed` response is \
missing `turn.status`"
.to_string())),
other => Some(Err(extract_turn_completed_error_message(response_value)
.unwrap_or_else(|| {
format!("Codex app-server turn ended with non-completed status `{other}`")
}))),
}
}
pub(super) fn is_interrupted_turn_completion_without_error(
response_value: &Value,
expected_turn_id: Option<&str>,
) -> bool {
if response_value.get("method").and_then(Value::as_str) != Some("turn/completed") {
return false;
}
let Some(expected_turn_id) = expected_turn_id else {
return false;
};
let Some(turn_id) = extract_turn_id_from_turn_completed_notification(response_value) else {
return false;
};
if turn_id != expected_turn_id {
return false;
}
let status = response_value
.get("params")
.and_then(|params| params.get("turn"))
.and_then(|turn| turn.get("status"))
.and_then(Value::as_str)
.unwrap_or_default();
if status != "interrupted" {
return false;
}
extract_turn_completed_error_message(response_value).is_none()
}
pub(super) fn extract_handoff_turn_id_from_completion(
response_value: &Value,
expected_turn_id: Option<&str>,
waiting_for_handoff_turn_completion: bool,
) -> Option<String> {
if expected_turn_id.is_some() || !waiting_for_handoff_turn_completion {
return None;
}
if response_value.get("method").and_then(Value::as_str) != Some("turn/completed") {
return None;
}
extract_turn_id_from_turn_completed_notification(response_value).map(ToString::to_string)
}
pub(super) fn extract_turn_completed_error_message(response_value: &Value) -> Option<String> {
let error = response_value
.get("params")
.and_then(|params| params.get("turn"))
.and_then(|turn| turn.get("error"))?;
let message = error.get("message").and_then(Value::as_str)?;
let error_info = error
.get("codexErrorInfo")
.and_then(Value::as_str)
.unwrap_or("");
if error_info.is_empty() {
Some(message.to_string())
} else {
Some(format!("[{error_info}] {message}"))
}
}
pub(super) fn is_context_window_exceeded_error(error_message: &str) -> bool {
let normalized = error_message.to_ascii_lowercase();
normalized.contains("contextwindowexceeded")
|| normalized.contains("context_window_exceeded")
|| normalized.contains("context window exceeded")
}
pub(super) fn extract_turn_id_from_turn_completed_notification(
response_value: &Value,
) -> Option<&str> {
let params = response_value.get("params")?;
params
.get("turn")
.and_then(|turn| turn.get("id"))
.and_then(Value::as_str)
.or_else(|| {
params
.get("turn")
.and_then(|turn| turn.get("turnId"))
.and_then(Value::as_str)
})
.or_else(|| {
params
.get("turn")
.and_then(|turn| turn.get("turn_id"))
.and_then(Value::as_str)
})
.or_else(|| params.get("turnId").and_then(Value::as_str))
.or_else(|| params.get("turn_id").and_then(Value::as_str))
}
pub(super) fn extract_item_started_progress(response_value: &Value) -> Option<String> {
if response_value.get("method").and_then(Value::as_str) != Some("item/started") {
return None;
}
let raw_item_type = response_value
.get("params")?
.get("item")?
.get("type")?
.as_str()?;
let normalized_item_type = camel_to_snake(raw_item_type);
agent::compact_codex_progress_message(&normalized_item_type)
}
pub(super) fn camel_to_snake(input: &str) -> String {
let mut result = String::with_capacity(input.len() + 4);
for (index, character) in input.chars().enumerate() {
if character.is_uppercase() {
if index > 0 {
result.push('_');
}
result.push(character.to_ascii_lowercase());
} else {
result.push(character);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferred_completed_assistant_message_prefers_latest_protocol_with_summary() {
let assistant_messages = vec![
"plain answer".to_string(),
r#"{"answer":"without summary","questions":[],"summary":null}"#.to_string(),
r#"{"answer":"with summary","questions":[],"summary":{"session":"done","turn":"now"}}"#
.to_string(),
];
let preferred = preferred_completed_assistant_message(&assistant_messages);
assert_eq!(
preferred,
r#"{"answer":"with summary","questions":[],"summary":{"session":"done","turn":"now"}}"#
);
}
#[test]
fn preferred_completed_assistant_message_falls_back_to_protocol_then_plain_text() {
let protocol_messages = vec![
"plain answer".to_string(),
r#"{"answer":"protocol answer","questions":[],"summary":null}"#.to_string(),
];
let plain_messages = vec![" ".to_string(), " plain answer ".to_string()];
let protocol_preferred = preferred_completed_assistant_message(&protocol_messages);
let plain_preferred = preferred_completed_assistant_message(&plain_messages);
assert_eq!(
protocol_preferred,
r#"{"answer":"protocol answer","questions":[],"summary":null}"#
);
assert_eq!(plain_preferred, "plain answer");
}
#[test]
fn extract_turn_id_from_turn_start_response_supports_nested_and_flat_fields() {
let nested_response = serde_json::json!({
"result": {
"turn": {
"turnId": "nested-turn"
}
}
});
let flat_response = serde_json::json!({
"result": {
"turn_id": "flat-turn"
}
});
let nested_turn_id = extract_turn_id_from_turn_start_response(&nested_response);
let flat_turn_id = extract_turn_id_from_turn_start_response(&flat_response);
assert_eq!(nested_turn_id, Some("nested-turn".to_string()));
assert_eq!(flat_turn_id, Some("flat-turn".to_string()));
}
#[test]
fn extract_item_started_progress_normalizes_camel_case_item_type() {
let response_value = serde_json::json!({
"method": "item/started",
"params": {
"item": {
"type": "commandExecution"
}
}
});
let progress = extract_item_started_progress(&response_value);
assert_eq!(progress, Some("Running a command".to_string()));
}
#[test]
fn extract_turn_id_from_turn_started_notification_supports_nested_flat_turn_fields() {
let response_value = serde_json::json!({
"method": "turn/started",
"params": {
"turn": {
"turn_id": "turn-nested"
}
}
});
let turn_id = extract_turn_id_from_turn_started_notification(&response_value);
assert_eq!(turn_id, Some("turn-nested".to_string()));
}
#[test]
fn extract_turn_id_from_turn_started_notification_rejects_other_methods() {
let response_value = serde_json::json!({
"method": "turn/completed",
"params": {
"turnId": "turn-flat"
}
});
let turn_id = extract_turn_id_from_turn_started_notification(&response_value);
assert_eq!(turn_id, None);
}
#[test]
fn is_context_window_exceeded_error_detects_codex_camel_case_suffix() {
let message =
"[contextWindowExceeded] Codex ran out of room in the model's context window.";
let is_overflow = is_context_window_exceeded_error(message);
assert!(is_overflow);
}
#[test]
fn extract_agent_message_delta_supports_v2_plan_and_reasoning_deltas() {
let plan_response = serde_json::json!({
"method": "item/plan/delta",
"params": {
"delta": "plan text"
}
});
let reasoning_response = serde_json::json!({
"method": "item/reasoning/textDelta",
"params": {
"delta": "thought text"
}
});
let plan_delta = extract_agent_message_delta(&plan_response);
let reasoning_delta = extract_agent_message_delta(&reasoning_response);
assert_eq!(
plan_delta,
Some(ExtractedAgentMessage {
message: "plan text".to_string(),
phase: Some("plan".to_string()),
})
);
assert_eq!(
reasoning_delta,
Some(ExtractedAgentMessage {
message: "thought text".to_string(),
phase: Some("thinking".to_string()),
})
);
}
#[test]
fn extract_agent_message_delta_ignores_blank_or_non_reasoning_items() {
let blank_response = serde_json::json!({
"method": "item/reasoning/text_delta",
"params": {
"delta": " "
}
});
let non_reasoning_response = serde_json::json!({
"method": "item/updated",
"params": {
"item": {
"type": "message",
"delta": "visible"
}
}
});
let blank_delta = extract_agent_message_delta(&blank_response);
let non_reasoning_delta = extract_agent_message_delta(&non_reasoning_response);
assert_eq!(blank_delta, None);
assert_eq!(non_reasoning_delta, None);
}
#[test]
fn extract_agent_message_returns_completed_text_and_content_parts() {
let text_response = serde_json::json!({
"method": "item/completed",
"params": {
"item": {
"type": "assistant_message",
"phase": "final",
"text": "final text"
}
}
});
let content_response = serde_json::json!({
"method": "item/completed",
"params": {
"item": {
"type": "agentMessage",
"content": [
{"text": "part one"},
{"text": "part two"}
]
}
}
});
let text_message = extract_agent_message(&text_response);
let content_message = extract_agent_message(&content_response);
assert_eq!(
text_message,
Some(ExtractedAgentMessage {
message: "final text".to_string(),
phase: Some("final".to_string()),
})
);
assert_eq!(
content_message,
Some(ExtractedAgentMessage {
message: "part one\n\npart two".to_string(),
phase: None,
})
);
}
#[test]
fn extract_agent_message_ignores_thought_phase_status_and_unsupported_items() {
let thought_response = serde_json::json!({
"method": "item/completed",
"params": {
"item": {
"type": "assistant_message",
"phase": "Thinking",
"text": "private thought"
}
}
});
let status_response = serde_json::json!({
"method": "item/completed",
"params": {
"item": {
"type": "assistant_message",
"text": "command completed"
}
}
});
let unsupported_response = serde_json::json!({
"method": "item/completed",
"params": {
"item": {
"type": "command_execution",
"text": "command output"
}
}
});
let thought_message = extract_agent_message(&thought_response);
let status_message = extract_agent_message(&status_response);
let unsupported_message = extract_agent_message(&unsupported_response);
assert_eq!(thought_message, None);
assert_eq!(status_message, None);
assert_eq!(unsupported_message, None);
}
#[test]
fn completed_item_type_and_phase_helpers_recognize_supported_variants() {
let agent_message_supported = is_completed_assistant_message_item_type("agent_message");
let assistant_message_supported =
is_completed_assistant_message_item_type("assistantmessage");
let command_unsupported = is_completed_assistant_message_item_type("command_execution");
let thought_phase = is_codex_thought_phase(Some(" reasoning "));
let final_phase = is_codex_thought_phase(Some("final"));
let missing_phase = is_codex_thought_phase(None);
assert!(agent_message_supported);
assert!(assistant_message_supported);
assert!(!command_unsupported);
assert!(thought_phase);
assert!(!final_phase);
assert!(!missing_phase);
}
#[test]
fn parse_turn_completed_ignores_other_turn_ids() {
let response_value = serde_json::json!({
"method": "turn/completed",
"params": {
"turn": {
"id": "delegated-turn",
"status": "completed"
}
}
});
let turn_result = parse_turn_completed(&response_value, Some("active-turn"));
assert_eq!(turn_result, None);
}
#[test]
fn parse_turn_completed_returns_success_and_error_statuses() {
let completed_response = serde_json::json!({
"method": "turn/completed",
"params": {
"turn": {
"id": "active-turn",
"status": "completed"
}
}
});
let failed_response = serde_json::json!({
"method": "turn/completed",
"params": {
"turn": {
"id": "active-turn",
"status": "failed",
"error": {
"message": "tool failed",
"codexErrorInfo": "tool_error"
}
}
}
});
let completed = parse_turn_completed(&completed_response, Some("active-turn"));
let failed = parse_turn_completed(&failed_response, Some("active-turn"));
assert_eq!(completed, Some(Ok(())));
assert_eq!(failed, Some(Err("[tool_error] tool failed".to_string())));
}
#[test]
fn parse_turn_completed_reports_missing_and_unknown_statuses() {
let missing_response = serde_json::json!({
"method": "turn/completed",
"params": {
"turn": {
"id": "active-turn"
}
}
});
let interrupted_response = serde_json::json!({
"method": "turn/completed",
"params": {
"turn": {
"id": "active-turn",
"status": "interrupted"
}
}
});
let missing = parse_turn_completed(&missing_response, Some("active-turn"));
let interrupted = parse_turn_completed(&interrupted_response, Some("active-turn"));
assert_eq!(
missing,
Some(Err("Codex app-server `turn/completed` response is \
missing `turn.status`"
.to_string(),))
);
assert_eq!(
interrupted,
Some(Err("Codex app-server turn ended with non-completed \
status `interrupted`"
.to_string(),))
);
}
#[test]
fn interrupted_turn_completion_without_error_requires_matching_turn_and_status() {
let response_value = serde_json::json!({
"method": "turn/completed",
"params": {
"turn": {
"id": "active-turn",
"status": "interrupted"
}
}
});
let errored_response = serde_json::json!({
"method": "turn/completed",
"params": {
"turn": {
"id": "active-turn",
"status": "interrupted",
"error": {
"message": "interrupted with error"
}
}
}
});
let interrupted =
is_interrupted_turn_completion_without_error(&response_value, Some("active-turn"));
let wrong_turn =
is_interrupted_turn_completion_without_error(&response_value, Some("other-turn"));
let errored =
is_interrupted_turn_completion_without_error(&errored_response, Some("active-turn"));
assert!(interrupted);
assert!(!wrong_turn);
assert!(!errored);
}
#[test]
fn extract_handoff_turn_id_from_completion_requires_waiting_without_expected_turn() {
let response_value = serde_json::json!({
"method": "turn/completed",
"params": {
"turn_id": "handoff-turn"
}
});
let handoff_turn_id = extract_handoff_turn_id_from_completion(&response_value, None, true);
let expected_turn_blocks_handoff =
extract_handoff_turn_id_from_completion(&response_value, Some("active-turn"), true);
let idle_wait_blocks_handoff =
extract_handoff_turn_id_from_completion(&response_value, None, false);
assert_eq!(handoff_turn_id, Some("handoff-turn".to_string()));
assert_eq!(expected_turn_blocks_handoff, None);
assert_eq!(idle_wait_blocks_handoff, None);
}
#[test]
fn extract_turn_completed_error_message_returns_plain_message_without_error_info() {
let response_value = serde_json::json!({
"params": {
"turn": {
"error": {
"message": "plain failure"
}
}
}
});
let message = extract_turn_completed_error_message(&response_value);
assert_eq!(message, Some("plain failure".to_string()));
}
#[test]
fn extract_turn_id_from_turn_completed_notification_supports_nested_flat_fields() {
let nested_response = serde_json::json!({
"params": {
"turn": {
"turnId": "nested-turn"
}
}
});
let flat_response = serde_json::json!({
"params": {
"turn_id": "flat-turn"
}
});
let nested_turn_id = extract_turn_id_from_turn_completed_notification(&nested_response);
let flat_turn_id = extract_turn_id_from_turn_completed_notification(&flat_response);
assert_eq!(nested_turn_id, Some("nested-turn"));
assert_eq!(flat_turn_id, Some("flat-turn"));
}
#[test]
fn camel_to_snake_preserves_lowercase_and_splits_uppercase_boundaries() {
let camel_case = "commandExecution";
let lowercase = "command";
let normalized_camel_case = camel_to_snake(camel_case);
let normalized_lowercase = camel_to_snake(lowercase);
assert_eq!(normalized_camel_case, "command_execution");
assert_eq!(normalized_lowercase, "command");
}
#[test]
fn extract_agent_message_delta_preserves_legacy_phase_labels() {
let response_value = serde_json::json!({
"method": "item/updated",
"params": {
"item": {
"type": "reasoning",
"phase": "plan",
"delta": "outline"
}
}
});
let delta = extract_agent_message_delta(&response_value);
assert_eq!(
delta,
Some(ExtractedAgentMessage {
message: "outline".to_string(),
phase: Some("plan".to_string()),
})
);
}
}