Report completed sub-agent activity on parent turns (#40437)

## Why

A Multi-Agent V2 child can finish after the parent turn that spawned it, so its
successful completion needs to remain associated with that parent turn in
notifications and history.

## What changed

- Add `completed` to `SubAgentActivityKind` and emit the corresponding started
  and completed item events on the spawning parent turn when a child succeeds.
- Persist completed activity as a canonical turn item in both legacy and
  paginated history, and expose it through app-server history and schemas.
- Render completed activity as terminal in the TUI without counting it as a
  separate tool call or tool-runtime event.

## Testing

- Cover successful completion in legacy and paginated history, late updates to
  completed parent turns, TUI rendering, analytics, and rollout tracing.
- Verify that terminal child errors do not emit completed activity.

GitOrigin-RevId: 6c71eb8b10e7327611c6af84f70e294e27d94d02
This commit is contained in:
jif
2026-08-24 17:07:21 +00:00
committed by copyberry
parent 7781f0a5a8
commit b705b6b076
40 changed files with 466 additions and 39 deletions
@@ -1631,6 +1631,7 @@ mod tests {
use codex_protocol::items::EnteredReviewModeItem as CoreEnteredReviewModeItem;
use codex_protocol::items::ExitedReviewModeItem as CoreExitedReviewModeItem;
use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment;
use codex_protocol::items::SubAgentActivityItem as CoreSubAgentActivityItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::UserMessageItem as CoreUserMessageItem;
use codex_protocol::items::build_hook_prompt_message;
@@ -1654,6 +1655,7 @@ mod tests {
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind;
use codex_protocol::protocol::ThreadRolledBackEvent;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
@@ -4480,6 +4482,57 @@ mod tests {
);
}
#[test]
fn completed_sub_agent_activity_updates_completed_parent_turn() {
let child_thread_id = ThreadId::new();
let child_path = codex_protocol::AgentPath::root()
.join("worker")
.expect("worker path");
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
})),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
})),
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-a".into(),
item: CoreTurnItem::SubAgentActivity(CoreSubAgentActivityItem {
id: "child-turn-completed".into(),
kind: CoreSubAgentActivityKind::Completed,
agent_thread_id: child_thread_id,
agent_path: child_path,
}),
started_at_ms: None,
completed_at_ms: 0,
})),
];
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].items,
vec![ThreadItem::SubAgentActivity {
id: "child-turn-completed".into(),
kind: crate::protocol::v2::SubAgentActivityKind::Completed,
agent_thread_id: child_thread_id.to_string(),
agent_path: "/root/worker".into(),
}]
);
}
#[test]
fn ignores_plain_user_response_items_in_rollout_replay() {
let items = vec![
@@ -1180,6 +1180,7 @@ pub enum SubAgentActivityKind {
Started,
Interacted,
Interrupted,
Completed,
}
impl From<CoreSubAgentActivityKind> for SubAgentActivityKind {
@@ -1188,6 +1189,7 @@ impl From<CoreSubAgentActivityKind> for SubAgentActivityKind {
CoreSubAgentActivityKind::Started => SubAgentActivityKind::Started,
CoreSubAgentActivityKind::Interacted => SubAgentActivityKind::Interacted,
CoreSubAgentActivityKind::Interrupted => SubAgentActivityKind::Interrupted,
CoreSubAgentActivityKind::Completed => SubAgentActivityKind::Completed,
}
}
}
@@ -3187,7 +3187,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
let sub_agent_activity_item = TurnItem::SubAgentActivity(SubAgentActivityItem {
id: "activity-1".to_string(),
kind: CoreSubAgentActivityKind::Interrupted,
kind: CoreSubAgentActivityKind::Completed,
agent_thread_id: receiver_thread_id,
agent_path: codex_protocol::AgentPath::root()
.join("worker")
@@ -3198,7 +3198,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
ThreadItem::from(sub_agent_activity_item),
ThreadItem::SubAgentActivity {
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Interrupted,
kind: SubAgentActivityKind::Completed,
agent_thread_id: receiver_thread_id.to_string(),
agent_path: "/root/worker".to_string(),
}