Make MultiAgentV2 interruption markers assistant-authored (#19124)

## Why

`MultiAgentV2` follow-up messages are delivered to agents as
assistant-authored `InterAgentCommunication` envelopes. When
`followup_task` used `interrupt: true`, the interrupted-turn guidance
was still persisted as a contextual user message, so model-visible
history made a system-generated interruption boundary look
user-authored.

This keeps interruption guidance consistent with the rest of the v2
inter-agent message stream while preserving the legacy marker shape for
non-v2 sessions.

## What changed

- Make `interrupted_turn_history_marker` feature-aware.
- Record the interrupted-turn marker as an assistant `OutputText`
message when `Feature::MultiAgentV2` is enabled.
- Keep the existing user contextual fragment for non-v2 sessions.
- Apply the same feature-aware marker to interrupted fork snapshots.
- Add coverage for the live `followup_task` interrupt path and the
helper-level v2 marker shape.

## Testing

- `cargo test -p codex-core
multi_agent_v2_followup_task_interrupts_busy_child_without_losing_message
-- --nocapture`
- `cargo test -p codex-core
multi_agent_v2_interrupted_marker_uses_assistant_output_message --
--nocapture`
- `cargo test -p codex-core interrupted_fork_snapshot -- --nocapture`
This commit is contained in:
jif-oai
2026-04-24 13:39:26 +02:00
committed by GitHub
Unverified
parent 21463a5074
commit 120aa07d81
5 changed files with 136 additions and 21 deletions
+47 -11
View File
@@ -446,12 +446,19 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() {
assert_eq!(
serde_json::to_value(
append_interrupted_boundary(committed_history, /*turn_id*/ None).get_rollout_items()
append_interrupted_boundary(
committed_history,
/*turn_id*/ None,
/*multi_agent_v2_enabled*/ false,
)
.get_rollout_items()
)
.expect("serialize interrupted fork history"),
serde_json::to_value(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
RolloutItem::ResponseItem(interrupted_turn_history_marker(
/*multi_agent_v2_enabled*/ false,
)),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
@@ -463,11 +470,18 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() {
);
assert_eq!(
serde_json::to_value(
append_interrupted_boundary(InitialHistory::New, /*turn_id*/ None).get_rollout_items()
append_interrupted_boundary(
InitialHistory::New,
/*turn_id*/ None,
/*multi_agent_v2_enabled*/ false,
)
.get_rollout_items()
)
.expect("serialize interrupted empty fork history"),
serde_json::to_value(vec![
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
RolloutItem::ResponseItem(interrupted_turn_history_marker(
/*multi_agent_v2_enabled*/ false,
)),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: None,
reason: TurnAbortReason::Interrupted,
@@ -484,7 +498,9 @@ fn interrupted_snapshot_is_not_mid_turn() {
let interrupted_history = InitialHistory::Forked(vec![
RolloutItem::ResponseItem(user_msg("hello")),
RolloutItem::ResponseItem(assistant_msg("partial")),
RolloutItem::ResponseItem(interrupted_turn_history_marker()),
RolloutItem::ResponseItem(interrupted_turn_history_marker(
/*multi_agent_v2_enabled*/ false,
)),
RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
@@ -503,6 +519,24 @@ fn interrupted_snapshot_is_not_mid_turn() {
);
}
#[test]
fn multi_agent_v2_interrupted_marker_uses_developer_input_message() {
let marker = interrupted_turn_history_marker(/*multi_agent_v2_enabled*/ true);
let ResponseItem::Message { role, content, .. } = marker else {
panic!("expected interrupted marker to be a message");
};
assert_eq!(role, "developer");
assert!(
matches!(
content.as_slice(),
[ContentItem::InputText { text }]
if text.contains(crate::context::TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
),
"expected interrupted marker to use developer InputText content"
);
}
#[test]
fn completed_legacy_event_history_is_not_mid_turn() {
let completed_history = InitialHistory::Forked(vec![
@@ -618,9 +652,10 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
.into_iter()
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
let interrupted_marker_json =
serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker()))
.expect("serialize interrupted marker");
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
interrupted_turn_history_marker(/*multi_agent_v2_enabled*/ false),
))
.expect("serialize interrupted marker");
let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg(
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: expected_turn_id,
@@ -809,9 +844,10 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
.into_iter()
.filter(|item| !matches!(item, RolloutItem::SessionMeta(_)))
.collect();
let interrupted_marker_json =
serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker()))
.expect("serialize interrupted marker");
let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem(
interrupted_turn_history_marker(/*multi_agent_v2_enabled*/ false),
))
.expect("serialize interrupted marker");
assert_eq!(
forked_rollout_items
.iter()