From 989c01a41a5b594a347883e60c4e01aaa59aa10f Mon Sep 17 00:00:00 2001 From: rb-oai Date: Tue, 6 Oct 2026 03:17:38 +0000 Subject: [PATCH] Handle partial answers consistently across agent workflows (#51249) ## Why Partial answers contain answer fragments while a turn is still in progress. Treating them as final answers defers mailbox delivery and can count empty fragments as empty final responses; filtering them from forked histories and guardian transcripts loses answer content. ## What changed - Allow mailbox preemption after `partial_answer` messages and keep mailbox delivery open. - Preserve partial answers in forked agent histories and include them as protected assistant entries in guardian transcripts. - Exclude empty partial answers from empty-final-response accounting for goals. ## Testing Add regression coverage for partial-answer phases in live events, sampling continuations, follow-up requests, and forked child requests, plus mailbox preemption, goal accounting, and guardian transcript inclusion. GitOrigin-RevId: 208c8933e211b42b6c2d0bec6952527b8c9c120e --- codex-rs/core/src/agent/control/spawn.rs | 5 +- codex-rs/core/src/agent/control_tests.rs | 18 ++ codex-rs/core/src/session/turn.rs | 8 +- codex-rs/core/src/stream_events_utils.rs | 4 +- .../core/src/stream_events_utils_tests.rs | 13 +- codex-rs/core/tests/suite/scenarios.rs | 3 + .../scenarios_mailbox_preemption_tests.rs | 20 +- .../tests/suite/scenarios_partial_answers.rs | 216 ++++++++++++++++++ ...emption_partial_answer_deferred_false.snap | 21 ++ ...eemption_partial_answer_deferred_true.snap | 23 ++ ..._answers__partial_answer_continuation.snap | 26 +++ ..._partial_answers__partial_answer_fork.snap | 43 ++++ codex-rs/ext/goal/src/accounting.rs | 2 +- codex-rs/ext/goal/tests/accounting.rs | 9 +- codex-rs/guardian-context/src/transcript.rs | 6 +- .../guardian-context/src/transcript_tests.rs | 13 ++ 16 files changed, 408 insertions(+), 22 deletions(-) create mode 100644 codex-rs/core/tests/suite/scenarios_partial_answers.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_false.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_true.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_continuation.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_fork.snap diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 5d4a9fa42e..db563dfe90 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -89,7 +89,10 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_context_baselines: bool RolloutItem::ResponseItem(envelope) => match &envelope.item { ResponseItem::Message { role, phase, .. } => match role.as_str() { "system" | "developer" | "user" => true, - "assistant" => *phase == Some(MessagePhase::FinalAnswer), + "assistant" => matches!( + phase, + Some(MessagePhase::PartialAnswer | MessagePhase::FinalAnswer) + ), _ => false, }, ResponseItem::FunctionCallOutput { call_id: None, .. } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7fff1f7c70..81846759f8 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -2449,6 +2449,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ), }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), + assistant_message("parent answer fragment", Some(MessagePhase::PartialAnswer)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), standalone_output, assistant_message("parent unknown phase", /*phase*/ None), @@ -2529,6 +2530,22 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }; assert!(assistant_position(&parent_thread.session.clone_history().await).is_some()); assert_eq!(assistant_position(&history), None); + let expected_partial_answer = parent_thread + .session + .clone_history() + .await + .raw_items() + .find(|item| { + matches!( + item, + ResponseItem::Message { + phase: Some(MessagePhase::PartialAnswer), + .. + } + ) + }) + .cloned() + .expect("parent answer fragment should be recorded"); let expected_final_answer = parent_thread .session .clone_history() @@ -2587,6 +2604,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let expected_history = [ expected_parent_seed, expected_developer_message, + expected_partial_answer, expected_final_answer, expected_standalone_output, ContextualUserFragment::into(MultiAgentRoleInstructions::Configured( diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2e6ec258c4..4047abc78a 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2750,11 +2750,13 @@ async fn try_run_sampling_request( cancellation_token: cancellation_token.child_token(), }; - // TODO: Allow mailbox preemption after PartialAnswer and keep delivery open - // in the completed-item deferral predicates. let preempt_for_mailbox_mail = match &item { ResponseItem::Message { role, phase, .. } => { - role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) + role == "assistant" + && matches!( + phase, + Some(MessagePhase::Commentary | MessagePhase::PartialAnswer) + ) } ResponseItem::Reasoning { .. } => true, ResponseItem::AgentMessage { .. } => false, diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 20ea780e99..0264a09389 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -291,7 +291,7 @@ pub(crate) async fn finalize_non_tool_response_item( Some(combined) }; let defers_mailbox_delivery_to_next_turn = - !matches!(agent_message.phase, Some(MessagePhase::Commentary)) + matches!(agent_message.phase, Some(MessagePhase::FinalAnswer) | None) && last_agent_message.is_some(); ( agent_message.memory_citation.clone(), @@ -532,7 +532,7 @@ fn completed_item_defers_mailbox_delivery_to_next_turn( ) -> bool { match item { ResponseItem::Message { role, phase, .. } => { - if role != "assistant" || matches!(phase, Some(MessagePhase::Commentary)) { + if role != "assistant" || !matches!(phase, Some(MessagePhase::FinalAnswer) | None) { return false; } // Treat `None` like final-answer text so untagged providers default diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index aec39664b3..53b20da436 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -479,14 +479,16 @@ async fn finalized_turn_item_defers_mailbox_for_contributed_visible_text() { assert!(finalized.facts.defers_mailbox_delivery_to_next_turn); } +#[test_case::test_case(MessagePhase::Commentary; "commentary")] +#[test_case::test_case(MessagePhase::PartialAnswer; "partial_answer")] #[tokio::test] -async fn finalized_turn_item_keeps_mailbox_open_for_commentary_text() { +async fn finalized_turn_item_keeps_mailbox_open_for_nonterminal_text(phase: MessagePhase) { let (mut session, turn_context) = make_session_and_context().await; let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); builder.turn_item_contributor(Arc::new(RewriteAgentMessageContributor)); session.services.extensions = Arc::new(builder.build()); let turn_store = ExtensionData::new(turn_context.sub_id.clone()); - let item = assistant_output_text_with_phase("still working", Some(MessagePhase::Commentary)); + let item = assistant_output_text_with_phase("still working", Some(phase)); let finalized = finalize_non_tool_response_item( &session, @@ -545,9 +547,10 @@ fn completed_item_defers_mailbox_delivery_for_unknown_phase_messages() { )); } -#[test] -fn completed_item_keeps_mailbox_delivery_open_for_commentary_messages() { - let item = assistant_output_text_with_phase("still working", Some(MessagePhase::Commentary)); +#[test_case::test_case(MessagePhase::Commentary; "commentary")] +#[test_case::test_case(MessagePhase::PartialAnswer; "partial_answer")] +fn completed_item_keeps_mailbox_delivery_open_for_nonterminal_messages(phase: MessagePhase) { + let item = assistant_output_text_with_phase("still working", Some(phase)); assert!(!completed_item_defers_mailbox_delivery_to_next_turn( &item, /*plan_mode*/ false, diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index dbd13ed73e..d6fb806405 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -104,6 +104,9 @@ mod agent_message_board; #[path = "scenarios_mailbox_preemption_tests.rs"] mod mailbox_preemption; +#[path = "scenarios_partial_answers.rs"] +mod partial_answers; + #[path = "scenarios_guardian_extra_policy.rs"] mod guardian_extra_policy; diff --git a/codex-rs/core/tests/suite/scenarios_mailbox_preemption_tests.rs b/codex-rs/core/tests/suite/scenarios_mailbox_preemption_tests.rs index 3d6d79ff1c..ecf4029eeb 100644 --- a/codex-rs/core/tests/suite/scenarios_mailbox_preemption_tests.rs +++ b/codex-rs/core/tests/suite/scenarios_mailbox_preemption_tests.rs @@ -25,13 +25,16 @@ use tokio::sync::oneshot; enum Boundary { Reasoning, Commentary, + PartialAnswer, } #[tracing_test::traced_test] #[test_case(Boundary::Reasoning, false; "reasoning_default")] #[test_case(Boundary::Commentary, false; "commentary_default")] +#[test_case(Boundary::PartialAnswer, false; "partial_answer_default")] #[test_case(Boundary::Reasoning, true; "reasoning_deferred")] #[test_case(Boundary::Commentary, true; "commentary_deferred")] +#[test_case(Boundary::PartialAnswer, true; "partial_answer_deferred")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mailbox_preemption_preserves_response_when_deferred( boundary: Boundary, @@ -44,14 +47,17 @@ async fn mailbox_preemption_preserves_response_when_deferred( responses::ev_reasoning_item("boundary", &["Preparing the next action"], &[]), "reasoning", ), - Boundary::Commentary => { + Boundary::Commentary | Boundary::PartialAnswer => { + let phase = match boundary { + Boundary::Commentary => "commentary", + Boundary::PartialAnswer => "partial_answer", + Boundary::Reasoning => unreachable!(), + }; let mut done = responses::ev_assistant_message("boundary", "I will update the plan."); - done["item"]["phase"] = json!("commentary"); - ( - responses::ev_message_item_added("boundary", ""), - done, - "commentary", - ) + done["item"]["phase"] = json!(phase); + let mut added = responses::ev_message_item_added("boundary", ""); + added["item"]["phase"] = json!(phase); + (added, done, phase) } }; let (streaming, _completions) = start_streaming_sse_server(vec![ diff --git a/codex-rs/core/tests/suite/scenarios_partial_answers.rs b/codex-rs/core/tests/suite/scenarios_partial_answers.rs new file mode 100644 index 0000000000..d36f149e94 --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_partial_answers.rs @@ -0,0 +1,216 @@ +//! Partial answers retain their phase in live events and subsequent model requests. + +use super::*; +use codex_protocol::models::MessagePhase; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn partial_answers_preserve_phase_across_sampling_continuation() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let mut added = ev_message_item_added("fragment", ""); + added["item"]["phase"] = json!("partial_answer"); + let mut partial = ev_assistant_message("fragment", "The first result is ready."); + partial["item"]["phase"] = json!("partial_answer"); + let mut continuing = ev_completed("partial-response"); + continuing["response"]["end_turn"] = json!(false); + let mut commentary = ev_assistant_message("progress", "Checking the remaining result."); + commentary["item"]["phase"] = json!("commentary"); + let mut final_answer = ev_assistant_message("answer", "The second result is ready."); + final_answer["item"]["phase"] = json!("final_answer"); + let mut completed = ev_completed("final-response"); + completed["response"]["end_turn"] = json!(true); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("partial-response"), + added, + ev_output_text_delta("The first result is ready."), + partial, + continuing, + ]), + sse(vec![ + ev_response_created("final-response"), + commentary, + final_answer, + completed, + ]), + sse(vec![ + ev_response_created("follow-up-response"), + ev_assistant_message("follow-up", "Both results are still in context."), + ev_completed("follow-up-response"), + ]), + ], + ) + .await; + let test = test_codex() + .with_config(configure_scenario_catalog) + .build_with_auto_env(&server) + .await?; + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![text( + "Give me both results.", + )])) + .await?; + + let started = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ItemStarted(event) => match &event.item { + TurnItem::AgentMessage(item) => Some((item.id.clone(), item.phase.clone())), + _ => None, + }, + _ => None, + }) + .await; + assert_eq!( + started, + ("fragment".to_string(), Some(MessagePhase::PartialAnswer)) + ); + wait_for_event(&test.codex, |event| { + assert!(!matches!(event, EventMsg::Error(_)), "{event:?}"); + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.submit_text_turn("Summarize the results.").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 3); + let phases = |index: usize| { + requests[index].body_json()["input"] + .as_array() + .expect("request input") + .iter() + .filter(|item| item["type"] == "message" && item["role"] == "assistant") + .map(|item| item["phase"].clone()) + .collect::>() + }; + assert_eq!(phases(/*index*/ 1), vec![json!("partial_answer")]); + assert_eq!( + phases(/*index*/ 2), + vec![ + json!("partial_answer"), + json!("commentary"), + json!("final_answer") + ] + ); + insta::assert_snapshot!( + "partial_answer_continuation", + context_snapshot::format_request_history_snapshot( + "A nonterminal answer continues sampling; its phase survives an ordinary follow-up.", + &requests, + &ContextSnapshotOptions::default().rewrite_known_segments(), + ) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn partial_answers_survive_forked_child_requests() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let mut partial = ev_assistant_message("fragment", "The first result is ready."); + partial["item"]["phase"] = json!("partial_answer"); + let mut commentary = ev_assistant_message("progress", "Checking the remaining result."); + commentary["item"]["phase"] = json!("commentary"); + let mut final_answer = ev_assistant_message("answer", "The second result is ready."); + final_answer["item"]["phase"] = json!("final_answer"); + let done = sse(vec![ + ev_assistant_message("done", "Done."), + ev_completed("done"), + ]); + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("results"), + partial, + commentary, + final_answer, + ev_completed("results"), + ]), + sse(vec![ + ev_function_call_with_namespace( + "spawn-worker", + "collaboration", + "spawn_agent", + &json!({ + "task_name": "worker", + "message": "Check both inherited results.", + "fork_turns": "all", + }) + .to_string(), + ), + ev_completed("spawn"), + ]), + // Parent continuation and child inference may arrive in either order. + done.clone(), + done, + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + configure_scenario_catalog(config); + config + .features + .enable(Feature::Collab) + .expect("enable agents"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("enable agent paths"); + }) + .build_with_auto_env(&server) + .await?; + test.submit_turn("Give me both results.").await?; + test.submit_turn("Have a worker check both results.") + .await?; + let child_id = test + .thread_manager + .list_thread_ids() + .await + .into_iter() + .find(|id| *id != test.session_configured.thread_id) + .expect("forked child"); + let child = test.thread_manager.get_thread(child_id).await?; + wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let child_requests = mock + .requests() + .into_iter() + .filter(|request| { + request.body_json()["client_metadata"]["thread_id"] == child_id.to_string() + }) + .collect::>(); + assert_eq!(child_requests.len(), 1); + let body = child_requests[0].body_json(); + let assistant_messages = body["input"] + .as_array() + .expect("child request input") + .iter() + .filter(|item| item["type"] == "message" && item["role"] == "assistant") + .map(|item| (item["phase"].clone(), item["content"].clone())) + .collect::>(); + assert_eq!( + assistant_messages, + vec![ + ( + json!("partial_answer"), + json!([{"type":"output_text", "text":"The first result is ready."}]) + ), + ( + json!("final_answer"), + json!([{"type":"output_text", "text":"The second result is ready."}]) + ), + ] + ); + insta::assert_snapshot!( + "partial_answer_fork", + context_snapshot::format_request_history_snapshot( + "A forked worker receives both stable answer fragments, with their phases preserved and parent commentary filtered out.", + &child_requests, + &ContextSnapshotOptions::default().rewrite_known_segments(), + ) + ); + child.shutdown_and_wait().await?; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_false.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_false.snap new file mode 100644 index 0000000000..0799eb263a --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_false.snap @@ -0,0 +1,21 @@ +--- +source: core/tests/suite/scenarios_mailbox_preemption_tests.rs +expression: "context_snapshot::format_context_snapshot(\"Worker messages arrive during a response. Deferred mailbox preemption lets the planned tool execute before the next request receives both messages.\",\n&entries, &ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: Worker messages arrive during a response. Deferred mailbox preemption lets the planned tool execute before the next request receives both messages. + +## Window 1 +-- request 1 (request) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Plan the next action while the worker finishes. +-- request 2 (request) -- +04:message/assistant: + I will update the plan. +05:agent_message +06:agent_message diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_true.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_true.snap new file mode 100644 index 0000000000..ae06d551a8 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__mailbox_preemption__mailbox_preemption_partial_answer_deferred_true.snap @@ -0,0 +1,23 @@ +--- +source: core/tests/suite/scenarios_mailbox_preemption_tests.rs +expression: "context_snapshot::format_context_snapshot(\"Worker messages arrive during a response. Deferred mailbox preemption lets the planned tool execute before the next request receives both messages.\",\n&entries, &ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: Worker messages arrive during a response. Deferred mailbox preemption lets the planned tool execute before the next request receives both messages. + +## Window 1 +-- request 1 (request) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Plan the next action while the worker finishes. +-- request 2 (request) -- +04:message/assistant: + I will update the plan. +05:function_call/update_plan:{"plan":[{"status":"in_progress","step":"Reply to the user"}]} +06:function_call_output:Plan updated +07:agent_message +08:agent_message diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_continuation.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_continuation.snap new file mode 100644 index 0000000000..71def9b1a1 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_continuation.snap @@ -0,0 +1,26 @@ +--- +source: core/tests/suite/scenarios_partial_answers.rs +expression: "context_snapshot::format_request_history_snapshot(\"A nonterminal answer continues sampling; its phase survives an ordinary follow-up.\",\n&requests, &ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: A nonterminal answer continues sampling; its phase survives an ordinary follow-up. + +## Window 1 +-- request 1 (turn) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Give me both results. +-- request 2 (turn) -- +04:message/assistant: + The first result is ready. +-- request 3 (turn) -- +05:message/assistant: + Checking the remaining result. +06:message/assistant: + The second result is ready. +07:message/user: + Summarize the results. diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_fork.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_fork.snap new file mode 100644 index 0000000000..ca81d28fdc --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__partial_answers__partial_answer_fork.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/scenarios_partial_answers.rs +expression: "context_snapshot::format_request_history_snapshot(\"A forked worker receives both stable answer fragments, with their phases preserved and parent commentary filtered out.\",\n&child_requests, &ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: A forked worker receives both stable answer fragments, with their phases preserved and parent commentary filtered out. + +## Window 1 +-- request 1 (turn) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Give me both results. +04:message/assistant: + The first result is ready. +05:message/assistant: + The second result is ready. +06:message/user: + Have a worker check both results. +07:message/developer: + You are an agent in a team of agents collaborating to complete a task. + + You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All ag...nd capable, and have access to the same set of tools. [hash=876a93c54d3f61d6] + + You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an... `send_message` to pass a message to a running agent. [hash=824c12425c7626dc] + Child agents can also spawn their own sub-agents. + + When you provide a response in the final channel, that content is immediately delivered back to your parent agent. + + - All agents use the same current working directory. + - As a result, edits made by one agent are immediately visible to all other agents. + + When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling. + + There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you. + + Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...fork_turns` to `"none"` or a positive integer string. [hash=db3545796947884f] +08:message/developer: + +09:agent_message diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs index b2a89ce2ed..29b5610308 100644 --- a/codex-rs/ext/goal/src/accounting.rs +++ b/codex-rs/ext/goal/src/accounting.rs @@ -172,7 +172,7 @@ impl GoalAccountingState { }); turn.has_activity |= has_text || message.questions.is_some(); turn.empty_final |= - !has_text && !matches!(message.phase, Some(MessagePhase::Commentary)); + !has_text && matches!(message.phase, Some(MessagePhase::FinalAnswer) | None); } TurnItem::Reasoning(reasoning) => { turn.has_activity |= reasoning diff --git a/codex-rs/ext/goal/tests/accounting.rs b/codex-rs/ext/goal/tests/accounting.rs index 49f0d9251a..f41d21f101 100644 --- a/codex-rs/ext/goal/tests/accounting.rs +++ b/codex-rs/ext/goal/tests/accounting.rs @@ -76,6 +76,7 @@ fn empty_continuations_require_three_turns_without_activity_or_goal_changes() { ("goal", 5), ("reset", 6), ("missing final", 6), + ("partial answer", 6), ] { let state = GoalAccountingState::default(); for turn in 1..=blocking_turn { @@ -87,7 +88,13 @@ fn empty_continuations_require_three_turns_without_activity_or_goal_changes() { }; state.start_turn(&id, ModeKind::Default, &TokenUsage::default()); state.mark_turn_goal_active(&id, goal_id); - if !(turn == 3 && interruption == "missing final") { + if turn == 3 && interruption == "partial answer" { + let mut partial = empty_final.clone(); + if let TurnItem::AgentMessage(message) = &mut partial { + message.phase = Some(MessagePhase::PartialAnswer); + } + state.record_item(&id, &partial); + } else if !(turn == 3 && interruption == "missing final") { state.record_item(&id, &empty_final); } // Admission can finish after output arrives, but before turn-stop evaluation. diff --git a/codex-rs/guardian-context/src/transcript.rs b/codex-rs/guardian-context/src/transcript.rs index a0452be204..ff50ecc9d3 100644 --- a/codex-rs/guardian-context/src/transcript.rs +++ b/codex-rs/guardian-context/src/transcript.rs @@ -155,8 +155,10 @@ pub fn collect_transcript( ConversationTranscriptEntryKind::Developer } "assistant" - if matches!(phase, None | Some(MessagePhase::FinalAnswer)) - && !InterAgentCommunication::is_message_content(content) => + if matches!( + phase, + None | Some(MessagePhase::PartialAnswer | MessagePhase::FinalAnswer) + ) && !InterAgentCommunication::is_message_content(content) => { ConversationTranscriptEntryKind::ProtectedAssistant } diff --git a/codex-rs/guardian-context/src/transcript_tests.rs b/codex-rs/guardian-context/src/transcript_tests.rs index 2bf9152ba2..81a9747793 100644 --- a/codex-rs/guardian-context/src/transcript_tests.rs +++ b/codex-rs/guardian-context/src/transcript_tests.rs @@ -131,6 +131,15 @@ fn registered_transcript_filters_roles_and_preserves_node_repl_tool_attribution( phase: None, internal_chat_message_metadata_passthrough: None, }, + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "The first result is ready.".to_string(), + }], + phase: Some(MessagePhase::PartialAnswer), + internal_chat_message_metadata_passthrough: None, + }, ResponseItem::Message { id: None, role: "assistant".to_string(), @@ -187,6 +196,10 @@ fn registered_transcript_filters_roles_and_preserves_node_repl_tool_attribution( "Inspect the workspace." ), entry(ConversationTranscriptEntryKind::Developer, &approved_action), + entry( + ConversationTranscriptEntryKind::ProtectedAssistant, + "The first result is ready." + ), entry( ConversationTranscriptEntryKind::ProtectedAssistant, "Inspection complete."