feat: make interrupt state not final for multi-agents (#13850)

Make `interrupted` an agent state and make it not final. As a result, a
`wait` won't return on an interrupted agent and no notification will be
send to the parent agent.

The rationals are:
* If a user interrupt a sub-agent for any reason, you don't want the
parent agent to instantaneously ask the sub-agent to restart
* If a parent agent interrupt a sub-agent, no need to add a noisy
notification in the parent agen
This commit is contained in:
jif-oai 2026-03-16 16:39:40 +00:00 committed by GitHub
parent 18ad67549c
commit 3f266bcd68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 151 additions and 4 deletions

View file

@ -535,6 +535,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -5691,6 +5691,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -2292,6 +2292,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -41,6 +41,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -41,6 +41,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -217,6 +217,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -217,6 +217,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -217,6 +217,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -155,6 +155,7 @@
"enum": [
"pendingInit",
"running",
"interrupted",
"completed",
"errored",
"shutdown",

View file

@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CollabAgentStatus = "pendingInit" | "running" | "completed" | "errored" | "shutdown" | "notFound";
export type CollabAgentStatus = "pendingInit" | "running" | "interrupted" | "completed" | "errored" | "shutdown" | "notFound";

View file

@ -2417,6 +2417,74 @@ mod tests {
);
}
#[test]
fn reconstructs_interrupted_send_input_as_completed_collab_call() {
// `send_input(interrupt=true)` first stops the child's active turn, then redirects it with
// new input. The transient interrupted status should remain visible in agent state, but the
// collab tool call itself is still a successful redirect rather than a failed operation.
let sender = ThreadId::try_from("00000000-0000-0000-0000-000000000001")
.expect("valid sender thread id");
let receiver = ThreadId::try_from("00000000-0000-0000-0000-000000000002")
.expect("valid receiver thread id");
let events = vec![
EventMsg::UserMessage(UserMessageEvent {
message: "redirect".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::CollabAgentInteractionBegin(
codex_protocol::protocol::CollabAgentInteractionBeginEvent {
call_id: "send-1".into(),
sender_thread_id: sender,
receiver_thread_id: receiver,
prompt: "new task".into(),
},
),
EventMsg::CollabAgentInteractionEnd(
codex_protocol::protocol::CollabAgentInteractionEndEvent {
call_id: "send-1".into(),
sender_thread_id: sender,
receiver_thread_id: receiver,
receiver_agent_nickname: None,
receiver_agent_role: None,
prompt: "new task".into(),
status: AgentStatus::Interrupted,
},
),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].items.len(), 2);
assert_eq!(
turns[0].items[1],
ThreadItem::CollabAgentToolCall {
id: "send-1".into(),
tool: CollabAgentTool::SendInput,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender.to_string(),
receiver_thread_ids: vec![receiver.to_string()],
prompt: Some("new task".into()),
model: None,
reasoning_effort: None,
agents_states: [(
receiver.to_string(),
CollabAgentState {
status: crate::protocol::v2::CollabAgentStatus::Interrupted,
message: None,
},
)]
.into_iter()
.collect(),
}
);
}
#[test]
fn rollback_failed_error_does_not_mark_turn_failed() {
let events = vec![

View file

@ -4542,6 +4542,7 @@ pub enum CollabAgentToolCallStatus {
pub enum CollabAgentStatus {
PendingInit,
Running,
Interrupted,
Completed,
Errored,
Shutdown,
@ -4567,6 +4568,10 @@ impl From<CoreAgentStatus> for CollabAgentState {
status: CollabAgentStatus::Running,
message: None,
},
CoreAgentStatus::Interrupted => Self {
status: CollabAgentStatus::Interrupted,
message: None,
},
CoreAgentStatus::Completed(message) => Self {
status: CollabAgentStatus::Completed,
message,
@ -5886,6 +5891,17 @@ mod tests {
absolute_path("readable")
}
#[test]
fn collab_agent_state_maps_interrupted_status() {
assert_eq!(
CollabAgentState::from(CoreAgentStatus::Interrupted),
CollabAgentState {
status: CollabAgentStatus::Interrupted,
message: None,
}
);
}
#[test]
fn command_execution_request_approval_rejects_relative_additional_permission_paths() {
let err = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({

View file

@ -207,7 +207,7 @@ async fn on_event_updates_status_from_turn_aborted() {
reason: TurnAbortReason::Interrupted,
}));
let expected = AgentStatus::Errored("Interrupted".to_string());
let expected = AgentStatus::Interrupted;
assert_eq!(status, Some(expected));
}

View file

@ -7,7 +7,12 @@ pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option<AgentStatus> {
match msg {
EventMsg::TurnStarted(_) => Some(AgentStatus::Running),
EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())),
EventMsg::TurnAborted(ev) => Some(AgentStatus::Errored(format!("{:?}", ev.reason))),
EventMsg::TurnAborted(ev) => match ev.reason {
codex_protocol::protocol::TurnAbortReason::Interrupted => {
Some(AgentStatus::Interrupted)
}
_ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))),
},
EventMsg::Error(ev) => Some(AgentStatus::Errored(ev.message.clone())),
EventMsg::ShutdownComplete => Some(AgentStatus::Shutdown),
_ => None,
@ -15,5 +20,8 @@ pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option<AgentStatus> {
}
pub(crate) fn is_final(status: &AgentStatus) -> bool {
!matches!(status, AgentStatus::PendingInit | AgentStatus::Running)
!matches!(
status,
AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted
)
}

View file

@ -1260,6 +1260,7 @@ fn format_collab_status(status: &AgentStatus) -> String {
match status {
AgentStatus::PendingInit => "pending init".to_string(),
AgentStatus::Running => "running".to_string(),
AgentStatus::Interrupted => "interrupted".to_string(),
AgentStatus::Completed(Some(message)) => {
let preview = truncate_preview(message.trim(), 120);
if preview.is_empty() {
@ -1289,6 +1290,7 @@ fn style_for_agent_status(
match status {
AgentStatus::PendingInit | AgentStatus::Shutdown => processor.dimmed,
AgentStatus::Running => processor.cyan,
AgentStatus::Interrupted => processor.yellow,
AgentStatus::Completed(_) => processor.green,
AgentStatus::Errored(_) | AgentStatus::NotFound => processor.red,
}

View file

@ -815,6 +815,10 @@ impl From<CoreAgentStatus> for CollabAgentState {
status: CollabAgentStatus::Running,
message: None,
},
CoreAgentStatus::Interrupted => Self {
status: CollabAgentStatus::Interrupted,
message: None,
},
CoreAgentStatus::Completed(message) => Self {
status: CollabAgentStatus::Completed,
message,

View file

@ -228,6 +228,7 @@ pub enum CollabTool {
pub enum CollabAgentStatus {
PendingInit,
Running,
Interrupted,
Completed,
Errored,
Shutdown,

View file

@ -1518,6 +1518,8 @@ pub enum AgentStatus {
PendingInit,
/// Agent is currently running.
Running,
/// Agent's current turn was interrupted and it may receive more input.
Interrupted,
/// Agent is done. Contains the final assistant message.
Completed(Option<String>),
/// Agent encountered an error.

View file

@ -537,10 +537,13 @@ fn status_summary_line(status: &AgentStatus) -> Line<'static> {
status_summary_spans(status).into()
}
// Allow `.yellow()`
#[allow(clippy::disallowed_methods)]
fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
match status {
AgentStatus::PendingInit => vec![Span::from("Pending init").cyan()],
AgentStatus::Running => vec![Span::from("Running").cyan().bold()],
AgentStatus::Interrupted => vec![Span::from("Interrupted").yellow()],
AgentStatus::Completed(message) => {
let mut spans = vec![Span::from("Completed").green()];
if let Some(message) = message.as_ref() {
@ -762,6 +765,25 @@ mod tests {
assert_eq!(title.spans[6].style.fg, Some(Color::Magenta));
}
#[test]
fn collab_resume_interrupted_snapshot() {
let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")
.expect("valid sender thread id");
let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002")
.expect("valid robie thread id");
let cell = resume_end(CollabResumeEndEvent {
call_id: "call-resume".to_string(),
sender_thread_id,
receiver_thread_id: robie_id,
receiver_agent_nickname: Some("Robie".to_string()),
receiver_agent_role: Some("explorer".to_string()),
status: AgentStatus::Interrupted,
});
assert_snapshot!("collab_resume_interrupted", cell_to_text(&cell));
}
fn cell_to_text(cell: &PlainHistoryCell) -> String {
cell.display_lines(200)
.iter()

View file

@ -0,0 +1,6 @@
---
source: tui/src/multi_agents.rs
expression: cell_to_text(&cell)
---
• Resumed Robie [explorer]
└ Interrupted