Files
codex/codex-rs/rollout-trace/src/protocol_event_tests.rs
T
jif b705b6b076 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
2026-08-24 17:07:21 +00:00

152 lines
5.2 KiB
Rust

use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ExecCommandStatus;
use codex_protocol::protocol::SubAgentActivityEvent;
use codex_protocol::protocol::SubAgentActivityKind;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use super::ToolRuntimeTraceEvent;
use super::tool_runtime_trace_event;
use crate::ExecutionStatus;
#[test]
fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> {
let agent_thread_id = ThreadId::new();
let event = EventMsg::SubAgentActivity(SubAgentActivityEvent {
event_id: "call-spawn".to_string(),
occurred_at_ms: 1234,
agent_thread_id,
agent_path: AgentPath::try_from("/root/reviewer").map_err(anyhow::Error::msg)?,
kind: SubAgentActivityKind::Started,
});
let Some(ToolRuntimeTraceEvent::Ended {
tool_call_id,
status,
payload,
}) = tool_runtime_trace_event(&event)
else {
panic!("expected terminal tool runtime event");
};
assert_eq!(tool_call_id, "call-spawn");
assert_eq!(status, ExecutionStatus::Completed);
assert_eq!(
serde_json::to_value(payload)?,
json!({
"event_id": "call-spawn",
"occurred_at_ms": 1234,
"agent_thread_id": agent_thread_id,
"agent_path": "/root/reviewer",
"kind": "started"
})
);
Ok(())
}
#[test]
fn completed_sub_agent_activity_is_not_a_tool_runtime_event() -> anyhow::Result<()> {
let event = EventMsg::SubAgentActivity(SubAgentActivityEvent {
event_id: "child-turn-completed".to_string(),
occurred_at_ms: 1234,
agent_thread_id: ThreadId::new(),
agent_path: AgentPath::try_from("/root/reviewer").map_err(anyhow::Error::msg)?,
kind: SubAgentActivityKind::Completed,
});
assert!(tool_runtime_trace_event(&event).is_none());
Ok(())
}
#[test]
fn exec_command_trace_payloads_use_inferred_native_cwd() -> anyhow::Result<()> {
// Convention inference depends on the URI spelling, not the test host, so exercise both
// Windows and POSIX paths on every platform.
let begin = EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-begin".to_string(),
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
process_id: Some("process-1".to_string()),
turn_id: "turn-1".to_string(),
started_at_ms: 1234,
command: vec!["pwd".to_string()],
cwd: "file:///C:/windows".parse()?,
parsed_cmd: Vec::new(),
source: ExecCommandSource::Agent,
interaction_input: None,
});
let end = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-end".to_string(),
plugin_id: Some("sample@openai-curated".to_string()),
script_path: Some("scripts/run.py".to_string()),
process_id: None,
turn_id: "turn-1".to_string(),
completed_at_ms: 2345,
command: vec!["pwd".to_string()],
cwd: "file:///workspace/project".parse()?,
parsed_cmd: Vec::new(),
source: ExecCommandSource::UnifiedExecInteraction,
interaction_input: Some("input".to_string()),
stdout: "output".to_string(),
stderr: String::new(),
aggregated_output: "output".to_string(),
exit_code: 0,
duration: Duration::from_millis(250),
formatted_output: "output".to_string(),
status: ExecCommandStatus::Completed,
});
let Some(ToolRuntimeTraceEvent::Started { payload, .. }) = tool_runtime_trace_event(&begin)
else {
panic!("expected started tool runtime event");
};
assert_eq!(
serde_json::to_value(payload)?,
json!({
"call_id": "call-begin",
"plugin_id": "sample@openai-curated",
"script_path": "scripts/run.py",
"process_id": "process-1",
"turn_id": "turn-1",
"started_at_ms": 1234,
"command": ["pwd"],
"cwd": r"C:\windows",
"parsed_cmd": [],
"source": "agent"
})
);
let Some(ToolRuntimeTraceEvent::Ended { payload, .. }) = tool_runtime_trace_event(&end) else {
panic!("expected ended tool runtime event");
};
assert_eq!(
serde_json::to_value(payload)?,
json!({
"call_id": "call-end",
"plugin_id": "sample@openai-curated",
"script_path": "scripts/run.py",
"turn_id": "turn-1",
"completed_at_ms": 2345,
"command": ["pwd"],
"cwd": "/workspace/project",
"parsed_cmd": [],
"source": "unified_exec_interaction",
"interaction_input": "input",
"stdout": "output",
"stderr": "",
"aggregated_output": "output",
"exit_code": 0,
"duration": {"secs": 0, "nanos": 250000000},
"formatted_output": "output",
"status": "completed"
})
);
Ok(())
}