fix: one shot end of turn (#16308)

Fix the death of the end of turn watcher
This commit is contained in:
jif-oai
2026-03-31 11:11:33 +02:00
committed by GitHub
parent 20f43c1e05
commit 873e466549
3 changed files with 304 additions and 22 deletions
@@ -16,7 +16,10 @@ use crate::protocol::Op;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionSource;
use crate::protocol::SubAgentSource;
use crate::protocol::TurnAbortReason;
use crate::protocol::TurnAbortedEvent;
use crate::protocol::TurnCompleteEvent;
use crate::session_prefix::format_subagent_notification_message;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
@@ -1119,6 +1122,210 @@ async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "boot worker",
"task_name": "worker"
})),
))
.await
.expect("spawn worker");
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker should resolve");
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
let first_turn = thread.codex.session.new_default_turn().await;
thread
.codex
.session
.send_event(
first_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: first_turn.sub_id.clone(),
last_agent_message: Some("first done".to_string()),
}),
)
.await;
AssignTaskHandlerV2
.handle(invocation(
session,
turn,
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
})),
))
.await
.expect("assign_task should succeed");
let second_turn = thread.codex.session.new_default_turn().await;
thread
.codex
.session
.send_event(
second_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: second_turn.sub_id.clone(),
last_agent_message: Some("second done".to_string()),
}),
)
.await;
let first_notification = format_subagent_notification_message(
worker_path.as_str(),
&AgentStatus::Completed(Some("first done".to_string())),
);
let second_notification = format_subagent_notification_message(
worker_path.as_str(),
&AgentStatus::Completed(Some("second done".to_string())),
);
let notifications = timeout(Duration::from_secs(5), async {
loop {
let notifications = manager
.captured_ops()
.into_iter()
.filter_map(|(id, op)| {
(id == root.thread_id)
.then_some(op)
.and_then(|op| match op {
Op::InterAgentCommunication { communication }
if communication.author == worker_path
&& communication.recipient == AgentPath::root()
&& communication.other_recipients.is_empty()
&& !communication.trigger_turn =>
{
Some(communication.content)
}
_ => None,
})
})
.collect::<Vec<_>>();
let first_count = notifications
.iter()
.filter(|message| **message == first_notification)
.count();
let second_count = notifications
.iter()
.filter(|message| **message == second_notification)
.count();
if first_count == 1 && second_count == 1 {
break notifications;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("parent should receive one completion notification per child turn");
assert_eq!(notifications.len(), 2);
}
#[tokio::test]
async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "boot worker",
"task_name": "worker"
})),
))
.await
.expect("spawn worker");
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker should resolve");
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let aborted_turn = thread.codex.session.new_default_turn().await;
thread
.codex
.session
.send_event(
aborted_turn.as_ref(),
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(aborted_turn.sub_id.clone()),
reason: TurnAbortReason::Interrupted,
}),
)
.await;
let notifications = manager
.captured_ops()
.into_iter()
.filter_map(|(id, op)| {
(id == root.thread_id)
.then_some(op)
.and_then(|op| match op {
Op::InterAgentCommunication { communication }
if communication.author.as_str() == "/root/worker"
&& communication.recipient == AgentPath::root()
&& communication.other_recipients.is_empty()
&& !communication.trigger_turn =>
{
Some(communication.content)
}
_ => None,
})
})
.collect::<Vec<_>>();
assert_eq!(notifications, Vec::<String>::new());
}
#[tokio::test]
async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() {
let (mut session, mut turn) = make_session_and_context().await;