Record resolved model and reasoning effort in sub-agent activity (#51463)

## What changed

Add `model` and `reasoningEffort` to `subAgentActivity` items for multi-agent v2 spawns. Capture startup settings before the child begins its turn and resolve the reported effort against model metadata, including model defaults, while preserving the child's configured effort.

Carry these fields through live notifications, persisted activity, thread history, and legacy rollout migration. Update the JSON schemas and generated TypeScript and Python types. Older records and other activity kinds retain null values.

## Testing

Add coverage for resolved spawn settings matching the child's request across legacy and paginated history, preserving configured effort, forwarding metadata through events and history, and reading and migrating older records without the new fields.

GitOrigin-RevId: a9e4241ae21bb8db1ead293f78ffba8257cca0ae
This commit is contained in:
Steve Coffey
2026-10-06 19:29:40 +00:00
committed by copyberry
parent 44984d2081
commit b0a6b8d86f
51 changed files with 749 additions and 62 deletions
@@ -525,12 +525,16 @@ async fn turn_event_counts_completed_tool_items() {
agents_states: Default::default(),
},
ThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "sub-agent-activity-1".to_string(),
kind: SubAgentActivityKind::Interacted,
agent_thread_id: "thread-child".to_string(),
agent_path: "/root/child".to_string(),
},
ThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "sub-agent-activity-completed".to_string(),
kind: SubAgentActivityKind::Completed,
agent_thread_id: "thread-child".to_string(),
@@ -5233,6 +5233,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -22598,6 +22598,24 @@
"kind": {
"$ref": "#/definitions/v2/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -20312,6 +20312,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1392,6 +1392,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1392,6 +1392,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1595,6 +1595,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2209,6 +2209,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1392,6 +1392,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2000,6 +2000,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2000,6 +2000,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2000,6 +2000,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2261,6 +2261,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2000,6 +2000,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2209,6 +2209,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2000,6 +2000,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1595,6 +1595,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -2000,6 +2000,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1595,6 +1595,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1595,6 +1595,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -1595,6 +1595,24 @@
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"model": {
"description": "Resolved model at sub-agent creation; absent from older records and other activities.",
"type": [
"string",
"null"
]
},
"reasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
],
"description": "Resolved reasoning effort at sub-agent creation, when known."
},
"type": {
"enum": [
"subAgentActivity"
@@ -122,4 +122,12 @@ reasoningEffort: ReasoningEffort | null,
/**
* Last known status of the target agents, when available.
*/
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep" } & SleepItem | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity",
/**
* Resolved model at sub-agent creation; absent from older records and other activities.
*/
model: string | null,
/**
* Resolved reasoning effort at sub-agent creation, when known.
*/
reasoningEffort: ReasoningEffort | null, id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep" } & SleepItem | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
@@ -183,6 +183,8 @@ pub fn item_event_to_server_notification(
}
EventMsg::SubAgentActivity(activity) => {
let item = ThreadItem::SubAgentActivity {
model: activity.model,
reasoning_effort: activity.reasoning_effort,
id: activity.event_id,
kind: activity.kind.into(),
agent_thread_id: activity.agent_thread_id.to_string(),
@@ -1022,6 +1022,8 @@ impl ThreadHistoryBuilder {
payload: &codex_protocol::protocol::SubAgentActivityEvent,
) {
self.upsert_item_in_current_turn(ThreadItem::SubAgentActivity {
model: payload.model.clone(),
reasoning_effort: payload.reasoning_effort.clone(),
id: payload.event_id.clone(),
kind: payload.kind.into(),
agent_thread_id: payload.agent_thread_id.to_string(),
@@ -5125,56 +5127,96 @@ mod tests {
#[test]
fn completed_sub_agent_activity_updates_completed_parent_turn() {
use codex_protocol::protocol::HasLegacyEvent;
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_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
})),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
root_turn_id: None,
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);
let spawn = ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-a".into(),
item: CoreTurnItem::SubAgentActivity(CoreSubAgentActivityItem {
model: Some("gpt-5".into()),
reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::High),
id: "spawn-child".into(),
kind: CoreSubAgentActivityKind::Started,
agent_thread_id: child_thread_id,
agent_path: child_path.clone(),
}),
started_at_ms: Some(1),
completed_at_ms: 2,
};
let expected_spawn = ThreadItem::from(spawn.item.clone());
let legacy_spawn = spawn
.as_legacy_events(/*show_raw_agent_reasoning*/ false)
.pop()
.expect("legacy spawn activity");
let crate::ServerNotification::ItemCompleted(notification) =
crate::item_event_to_server_notification(legacy_spawn.clone(), "parent", "turn-a")
else {
panic!("expected item completed");
};
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(),
}]
(notification.item, notification.completed_at_ms),
(expected_spawn.clone(), 2)
);
for spawn in [EventMsg::ItemCompleted(spawn), legacy_spawn] {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_attribution: None,
turn_id: "turn-a".into(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
})),
RolloutItem::EventMsg(spawn),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
root_turn_id: None,
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 {
model: None,
reasoning_effort: None,
id: "child-turn-completed".into(),
kind: CoreSubAgentActivityKind::Completed,
agent_thread_id: child_thread_id,
agent_path: child_path.clone(),
}),
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![
expected_spawn.clone(),
ThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
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]
@@ -395,6 +395,10 @@ pub enum ThreadItem {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
SubAgentActivity {
/// Resolved model at sub-agent creation; absent from older records and other activities.
model: Option<String>,
/// Resolved reasoning effort at sub-agent creation, when known.
reasoning_effort: Option<ReasoningEffort>,
id: String,
kind: SubAgentActivityKind,
agent_thread_id: String,
@@ -978,6 +982,8 @@ impl From<CoreTurnItem> for ThreadItem {
.collect(),
},
CoreTurnItem::SubAgentActivity(activity) => ThreadItem::SubAgentActivity {
model: activity.model,
reasoning_effort: activity.reasoning_effort,
id: activity.id,
kind: activity.kind.into(),
agent_thread_id: activity.agent_thread_id.to_string(),
@@ -33,6 +33,7 @@ use codex_protocol::models::ImageReference as CoreImageReference;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry;
@@ -3446,8 +3447,10 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
);
let sub_agent_activity_item = TurnItem::SubAgentActivity(SubAgentActivityItem {
model: Some("gpt-5".into()),
reasoning_effort: Some(ReasoningEffort::High),
id: "activity-1".to_string(),
kind: CoreSubAgentActivityKind::Completed,
kind: CoreSubAgentActivityKind::Started,
agent_thread_id: receiver_thread_id,
agent_path: codex_protocol::AgentPath::root()
.join("worker")
@@ -3457,13 +3460,35 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
assert_eq!(
ThreadItem::from(sub_agent_activity_item),
ThreadItem::SubAgentActivity {
model: Some("gpt-5".into()),
reasoning_effort: Some(ReasoningEffort::High),
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Completed,
kind: SubAgentActivityKind::Started,
agent_thread_id: receiver_thread_id.to_string(),
agent_path: "/root/worker".to_string(),
}
);
let old_activity: SubAgentActivityItem = serde_json::from_value(json!({
"id": "old-activity",
"kind": "started",
"agent_thread_id": receiver_thread_id,
"agent_path": "/root/worker",
}))
.unwrap();
let old_wire_activity: ThreadItem = serde_json::from_value(json!({
"type": "subAgentActivity",
"id": "old-activity",
"kind": "started",
"agentThreadId": receiver_thread_id,
"agentPath": "/root/worker",
}))
.unwrap();
assert_eq!(
ThreadItem::from(TurnItem::SubAgentActivity(old_activity)),
old_wire_activity
);
let search_item = TurnItem::WebSearch(CoreWebSearchItem {
id: "search-1".to_string(),
query: "docs".to_string(),
@@ -3252,6 +3252,8 @@ mod tests {
thread_id: conversation_id,
turn_id: "turn-1".to_string(),
item: CoreTurnItem::SubAgentActivity(SubAgentActivityItem {
model: None,
reasoning_effort: None,
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Interrupted,
agent_thread_id: child_thread_id,
@@ -3286,6 +3288,8 @@ mod tests {
payload,
ItemCompletedNotification {
item: ThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "activity-1".to_string(),
kind: codex_app_server_protocol::SubAgentActivityKind::Interrupted,
agent_thread_id: child_thread_id_string,
+2
View File
@@ -53,6 +53,8 @@ pub trait AgentControl: Send + Sync {
) -> BoxFuture<'a, Result<ThreadId>>;
/// Start a child and accept its initial input, returning its effective settings.
/// V2 snapshots resolve effort against captured startup metadata without changing the
/// child's configured effort (for example, Ultra still enables proactive behavior).
fn spawn(
&self,
request: SpawnRequest,
@@ -73,6 +73,8 @@ impl LocalAgentControl {
initiating_thread_id,
parent_turn_id,
SubAgentActivityItem {
model: None,
reasoning_effort: None,
id: format!("subagent-completed-{}", outcome.turn_id),
kind: SubAgentActivityKind::Completed,
agent_thread_id: outcome.thread_id,
+23 -1
View File
@@ -900,6 +900,25 @@ impl LocalAgentControl {
}
let durability_wait = durability_wait_started_at.elapsed();
// Capture startup settings before the child can publish metadata for a later turn.
let spawn_config = if multi_agent_version == MultiAgentVersion::V2 {
let mut config = new_thread.thread.config_snapshot().await;
let model_info = new_thread
.thread
.thread_extension_data()
.get::<codex_protocol::openai_models::ModelInfo>()
.ok_or_else(|| {
CodexErr::Fatal("spawned thread is missing model metadata".to_string())
})?;
config.reasoning_effort = config
.reasoning_effort
.or_else(|| model_info.default_reasoning_level.clone())
.map(|effort| model_info.resolve_reasoning_effort(effort));
Some(config)
} else {
None
};
let start_options = TurnStartOptions {
parent_turn_id: options.parent_turn_id,
turn_trigger: options.turn_trigger,
@@ -956,7 +975,10 @@ impl LocalAgentControl {
metadata: agent_metadata,
status: self.get_status(new_thread.thread_id).await,
};
let config = new_thread.thread.config_snapshot().await;
let config = match spawn_config {
Some(config) => config,
None => new_thread.thread.config_snapshot().await,
};
let session_telemetry = new_thread
.thread
.session_telemetry()
+57
View File
@@ -68,6 +68,7 @@ use codex_protocol::models::InternalChatMessageMetadataPassthrough;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::ErrorEvent;
@@ -1863,6 +1864,62 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
wait_for_recorded_user_message(thread.as_ref(), "spawned").await;
}
#[test_case::test_case(None, ReasoningEffort::Medium; "model default")]
#[test_case::test_case(Some(ReasoningEffort::Ultra), ReasoningEffort::XHigh; "resolved ultra")]
#[tokio::test]
async fn v2_spawn_resolves_reported_effort_without_changing_child_selection(
selected_effort: Option<ReasoningEffort>,
reported_effort: ReasoningEffort,
) {
let (home, mut config) = test_config().await;
config
.features
.enable(Feature::MultiAgentV2)
.expect("enable v2");
config.model_reasoning_effort = selected_effort.clone();
let harness = AgentControlHarness::new_with_config(home, config).await;
let (parent_id, parent) = harness.start_thread().await;
let source = thread_spawn_source(
parent_id,
&parent.session_source,
next_thread_spawn_depth(&parent.session_source),
/*agent_role*/ None,
Some("worker".to_string()),
)
.expect("child source");
let (agent, snapshot) = harness
.control
.spawn(SpawnRequest {
caller: parent_id,
config: harness.config.clone(),
input: AgentInput::Message {
message: AgentMessage::Plaintext("child task".to_string()),
mode: MessageDeliveryMode::TriggerTurn,
},
source,
options: SpawnAgentOptions {
parent_thread_id: Some(parent_id),
..Default::default()
},
})
.await
.expect("spawn child");
let child = harness
.manager
.get_thread(agent.thread_id)
.await
.expect("child is registered");
assert_eq!(
(
snapshot.reasoning_effort,
child.config_snapshot().await.reasoning_effort,
),
(Some(reported_effort), selected_effort),
);
child.shutdown_and_wait().await.expect("shutdown child");
parent.shutdown_and_wait().await.expect("shutdown parent");
}
#[tokio::test]
async fn pending_environment_failure_reaches_child_and_grandchild() {
let (home, mut config) = test_config().await;
@@ -258,6 +258,8 @@ fn promotes_backing_agent_artifacts_once_without_a_client_request() {
started_at_ms: 0,
});
let subagent = completed_item(TurnItem::SubAgentActivity(SubAgentActivityItem {
model: None,
reasoning_effort: None,
id: "subagent-1".to_string(),
kind: SubAgentActivityKind::Started,
agent_thread_id: ThreadId::new(),
+16 -2
View File
@@ -2597,14 +2597,20 @@ async fn item_completion_without_a_start_uses_completion_timestamp() {
async fn subagent_activity_emits_matching_start_and_completion() {
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
let item = codex_protocol::items::SubAgentActivityItem {
model: Some("requested-model".to_string()),
reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::High),
id: "activity-1".to_string(),
kind: codex_protocol::protocol::SubAgentActivityKind::Started,
agent_thread_id: ThreadId::new(),
agent_path: AgentPath::root(),
};
crate::tools::handlers::multi_agents_v2::emit_sub_agent_activity(&session, &turn_context, item)
.await;
crate::tools::handlers::multi_agents_v2::emit_sub_agent_activity(
&session,
&turn_context,
item.clone(),
)
.await;
let EventMsg::ItemStarted(started) = rx.recv().await.expect("started item event").msg else {
panic!("expected started item event");
@@ -2613,6 +2619,14 @@ async fn subagent_activity_emits_matching_start_and_completion() {
else {
panic!("expected completed item event");
};
let TurnItem::SubAgentActivity(started_item) = started.item else {
panic!("expected started sub-agent activity");
};
let TurnItem::SubAgentActivity(completed_item) = completed.item else {
panic!("expected completed sub-agent activity");
};
assert_eq!(started_item, item);
assert_eq!(completed_item, item);
assert_eq!(completed.started_at_ms, Some(started.started_at_ms));
}
@@ -63,6 +63,8 @@ async fn handle_interrupt_agent(
&session,
&turn,
SubAgentActivityItem {
model: None,
reasoning_effort: None,
id: call_id,
agent_thread_id: agent_id,
agent_path,
@@ -87,6 +87,8 @@ pub(super) async fn handle_message_string_tool(
&session,
&turn,
SubAgentActivityItem {
model: None,
reasoning_effort: None,
id: call_id,
agent_thread_id: receiver_thread_id,
agent_path: receiver_agent_path,
@@ -228,6 +228,8 @@ async fn handle_spawn_agent(
&session,
turn,
SubAgentActivityItem {
model: Some(agent_snapshot.model.clone()),
reasoning_effort: agent_snapshot.reasoning_effort.clone(),
id: call_id,
agent_thread_id: new_thread_id,
agent_path: new_agent_path.clone(),
@@ -2211,14 +2211,20 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res
Ok(())
}
#[test_case(None, false; "encrypted")]
#[test_case(None, true; "plaintext")]
#[test_case(Some("gpt-5.6-luna"), false; "luna encrypted leaf")]
#[test_case(Some("gpt-5.5"), false; "legacy encrypted leaf")]
#[test_case(None, None, Some(ReasoningEffort::Ultra), Some("medium"), false, ThreadHistoryMode::Legacy; "encrypted")]
#[test_case(None, None, Some(ReasoningEffort::Ultra), Some("medium"), true, ThreadHistoryMode::Paginated; "plaintext")]
#[test_case(Some("gpt-5.6-luna"), None, Some(ReasoningEffort::Ultra), Some("medium"), false, ThreadHistoryMode::Legacy; "luna encrypted leaf")]
#[test_case(Some("gpt-5.5"), Some(ReasoningEffort::High), Some(ReasoningEffort::Ultra), Some("high"), false, ThreadHistoryMode::Paginated; "legacy encrypted leaf")]
#[test_case(None, None, Some(ReasoningEffort::Persistent), Some("disabled"), false, ThreadHistoryMode::Paginated; "inherited persistent effort")]
#[test_case(None, None, None, None, false, ThreadHistoryMode::Legacy; "unknown model without effort default")]
#[tokio::test]
async fn multi_agent_v2_spawn_sends_agent_message_to_child(
model: Option<&str>,
reasoning_effort: Option<ReasoningEffort>,
parent_reasoning_effort: Option<ReasoningEffort>,
expected_reasoning_effort: Option<&str>,
plaintext: bool,
history_mode: ThreadHistoryMode,
) -> Result<()> {
let output: &'static Mutex<Vec<u8>> = Box::leak(Box::new(Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
@@ -2249,6 +2255,9 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(
spawn_args["fork_turns"] = json!("none");
}
}
if let Some(reasoning_effort) = reasoning_effort {
spawn_args["reasoning_effort"] = json!(reasoning_effort);
}
let spawn_args = serde_json::to_string(&spawn_args)?;
let mut spawn_event = ev_function_call_with_namespace(
SPAWN_CALL_ID,
@@ -2297,8 +2306,10 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(
"koffing"
};
let mut builder = test_codex()
.with_history_mode(history_mode)
.with_model(parent_model)
.with_config(move |config| {
config.model_reasoning_effort = parent_reasoning_effort;
config
.features
.enable(Feature::Collab)
@@ -2317,8 +2328,31 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(
let test = builder.build(&server).await?;
let root_thread_id = test.session_configured.thread_id;
test.submit_turn(TURN_1_PROMPT).await?;
test.submit_text_turn(TURN_1_PROMPT).await?;
test.codex.ensure_rollout_materialized().await;
test.codex.flush_rollout().await?;
let rollout = codex_rollout::RolloutRecorder::get_rollout_history(
&test.codex.rollout_path().expect("parent rollout path"),
)
.await?;
let settings = rollout
.get_rollout_items()
.iter()
.find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::ItemCompleted(completed)) => match &completed.item {
TurnItem::SubAgentActivity(activity) if activity.id == SPAWN_CALL_ID => {
Some((activity.model.clone(), activity.reasoning_effort.clone()))
}
_ => None,
},
RolloutItem::EventMsg(EventMsg::SubAgentActivity(activity))
if activity.event_id == SPAWN_CALL_ID =>
{
Some((activity.model.clone(), activity.reasoning_effort.clone()))
}
_ => None,
})
.expect("persisted spawn activity");
// The response mock records candidate requests before its request matcher runs, so wait for
// the child request instead of assuming the latest recorded request is already it.
let deadline = Instant::now() + Duration::from_secs(2);
@@ -2335,6 +2369,25 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(
}
sleep(Duration::from_millis(10)).await;
};
let child_body = child_request.body_json();
let expected_settings = (
Some(model.unwrap_or(parent_model)),
expected_reasoning_effort,
);
assert_eq!(
(
settings.0.as_deref(),
settings.1.as_ref().map(ReasoningEffort::as_str),
),
expected_settings,
);
assert_eq!(
(
child_body["model"].as_str(),
child_body["reasoning"]["effort"].as_str(),
),
expected_settings,
);
let content = if plaintext {
vec![json!({
"type": "input_text",
@@ -2365,8 +2418,7 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(
"content": content,
})])
);
if let Some(model) = model {
assert_eq!(child_request.body_json()["model"], json!(model));
if model.is_some() {
assert!(
!child_request
.body_json()
@@ -2778,6 +2830,8 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message(
assert_eq!(
completed_item,
&SubAgentActivityItem {
model: None,
reasoning_effort: None,
id: format!("subagent-completed-{child_turn_id}"),
kind: SubAgentActivityKind::Completed,
agent_thread_id: child_thread_id,
@@ -3094,6 +3148,8 @@ async fn multi_agent_v2_peer_followup_completion_notifies_initiating_turn(
requester_thread_id,
requester_turn_id,
SubAgentActivityItem {
model: None,
reasoning_effort: None,
id: format!("subagent-completed-{worker_followup_turn_id}"),
kind: SubAgentActivityKind::Completed,
agent_thread_id: worker_thread_id,
+4
View File
@@ -359,6 +359,10 @@ pub struct CollabAgentToolCallItem {
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct SubAgentActivityItem {
/// Resolved model at sub-agent creation; absent from older records and other activities.
pub model: Option<String>,
/// Resolved reasoning effort at sub-agent creation, when known.
pub reasoning_effort: Option<ReasoningEffortConfig>,
pub id: String,
pub kind: SubAgentActivityKind,
pub agent_thread_id: ThreadId,
+2
View File
@@ -435,6 +435,8 @@ impl CollabAgentToolCallItem {
impl SubAgentActivityItem {
pub(crate) fn as_legacy_event(&self, occurred_at_ms: i64) -> EventMsg {
EventMsg::SubAgentActivity(SubAgentActivityEvent {
model: self.model.clone(),
reasoning_effort: self.reasoning_effort.clone(),
event_id: self.id.clone(),
occurred_at_ms,
agent_thread_id: self.agent_thread_id,
+4
View File
@@ -4381,6 +4381,10 @@ pub enum SubAgentActivityKind {
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct SubAgentActivityEvent {
/// Resolved model at sub-agent creation; absent from older records and other activities.
pub model: Option<String>,
/// Resolved reasoning effort at sub-agent creation, when known.
pub reasoning_effort: Option<ReasoningEffortConfig>,
pub event_id: String,
#[serde(default)]
pub occurred_at_ms: i64,
@@ -19,6 +19,8 @@ use crate::ExecutionStatus;
fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> {
let agent_thread_id = ThreadId::new();
let event = EventMsg::SubAgentActivity(SubAgentActivityEvent {
model: Some("gpt-5".into()),
reasoning_effort: Some(codex_protocol::openai_models::ReasoningEffort::High),
event_id: "call-spawn".to_string(),
occurred_at_ms: 1234,
agent_thread_id,
@@ -44,7 +46,9 @@ fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> {
"occurred_at_ms": 1234,
"agent_thread_id": agent_thread_id,
"agent_path": "/root/reviewer",
"kind": "started"
"kind": "started",
"model": "gpt-5",
"reasoning_effort": "high"
})
);
Ok(())
@@ -53,6 +57,8 @@ fn sub_agent_activity_is_a_terminal_tool_runtime_event() -> anyhow::Result<()> {
#[test]
fn completed_sub_agent_activity_is_not_a_tool_runtime_event() -> anyhow::Result<()> {
let event = EventMsg::SubAgentActivity(SubAgentActivityEvent {
model: None,
reasoning_effort: None,
event_id: "child-turn-completed".to_string(),
occurred_at_ms: 1234,
agent_thread_id: ThreadId::new(),
@@ -258,6 +258,8 @@ pub(super) fn completed_item(
)),
EventMsg::SubAgentActivity(event) => Some((
TurnItem::SubAgentActivity(SubAgentActivityItem {
model: event.model.clone(),
reasoning_effort: event.reasoning_effort.clone(),
id: event.event_id.clone(),
kind: event.kind,
agent_thread_id: event.agent_thread_id,
@@ -431,6 +431,22 @@ async fn migration_projects_explicit_and_implicit_legacy_completed_items() {
"payload": {"type": "agent_reasoning_raw_content", "text": "raw"}
}))
.expect("build legacy reasoning content");
let child_id = ThreadId::new();
let [old_activity, activity] = [None, Some("gpt-5")].map(|model| {
let mut payload = json!({
"type": "sub_agent_activity",
"event_id": if model.is_some() { "spawn" } else { "old-spawn" },
"kind": "started",
"agent_thread_id": child_id,
"agent_path": "/root/worker",
});
if let Some(model) = model {
payload["model"] = json!(model);
payload["reasoning_effort"] = json!("high");
}
serde_json::from_value(json!({"type": "event_msg", "payload": payload}))
.expect("build legacy activity")
});
write_rollout(
home.path(),
thread_id,
@@ -438,6 +454,8 @@ async fn migration_projects_explicit_and_implicit_legacy_completed_items() {
vec![
started("explicit"),
exec,
old_activity,
activity,
completed("explicit"),
reasoning,
raw_reasoning,
@@ -466,13 +484,13 @@ async fn migration_projects_explicit_and_implicit_legacy_completed_items() {
})
.await
.expect("read projected items");
assert_eq!(items.items.len(), 2);
assert_eq!(items.items.len(), 4);
assert_eq!(items.items[0].turn_id, "explicit");
assert_eq!(items.items[1].turn_id, turns.turns[1].turn_id);
assert_eq!(items.items[3].turn_id, turns.turns[1].turn_id);
let command: serde_json::Value =
serde_json::from_slice(&items.items[0].item_json).expect("parse projected command");
let reasoning: serde_json::Value =
serde_json::from_slice(&items.items[1].item_json).expect("parse projected reasoning");
serde_json::from_slice(&items.items[3].item_json).expect("parse projected reasoning");
assert_eq!(command["type"], "commandExecution");
let aggregated_output = command["aggregatedOutput"]
.as_str()
@@ -484,6 +502,21 @@ async fn migration_projects_explicit_and_implicit_legacy_completed_items() {
assert_eq!(reasoning["type"], "reasoning");
assert_eq!(reasoning["summary"], json!(["summary"]));
assert_eq!(reasoning["content"], json!(["raw"]));
for (item, model) in items.items[1..3].iter().zip([None, Some("gpt-5")]) {
assert_eq!(item.turn_id, "explicit");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&item.item_json).unwrap(),
json!({
"type": "subAgentActivity",
"id": if model.is_some() { "spawn" } else { "old-spawn" },
"kind": "started",
"agentThreadId": child_id,
"agentPath": "/root/worker",
"model": model,
"reasoningEffort": model.map(|_| "high"),
})
);
}
}
#[tokio::test]
@@ -1348,6 +1348,8 @@ async fn live_app_server_command_output_delta_transcript_snapshot() {
async fn live_app_server_sub_agent_activity_renders_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let activity = AppServerThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "activity-1".to_string(),
kind: codex_app_server_protocol::SubAgentActivityKind::Completed,
agent_thread_id: ThreadId::new().to_string(),
@@ -15,6 +15,8 @@ enum FinalizedEvent {
fn complete_subagent(chat: &mut ChatWidget, id: &str) {
let item = AppServerThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: id.to_string(),
kind: codex_app_server_protocol::SubAgentActivityKind::Completed,
agent_thread_id: ThreadId::new().to_string(),
+1
View File
@@ -1491,6 +1491,7 @@ fn turn_summary(turn: &Turn, include_outputs: bool, output_chars: usize) -> Valu
kind,
agent_thread_id,
agent_path,
..
} => json!({
"type": "subAgentActivity", "id": id, "kind": kind,
"agentThreadId": agent_thread_id, "agentPath": agent_path
+4
View File
@@ -681,6 +681,8 @@ mod tests {
#[test]
fn interacted_sub_agent_activity_does_not_change_liveness() {
let item = ThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Interacted,
agent_thread_id: ThreadId::new().to_string(),
@@ -694,6 +696,8 @@ mod tests {
fn completed_sub_agent_activity_stops_running_liveness() {
let thread_id = ThreadId::new();
let item = ThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Completed,
agent_thread_id: thread_id.to_string(),
@@ -155,6 +155,8 @@ fn tool_and_notice_projection_uses_normal_transcript_presentation() {
..image
}),
ThreadItem::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "agent-1".to_string(),
kind: SubAgentActivityKind::Completed,
agent_thread_id: "01912345-1234-7123-8123-123456789abc".to_string(),
@@ -5465,6 +5465,19 @@ class SubAgentActivityThreadItem(BaseModel):
agent_thread_id: Annotated[str, Field(alias="agentThreadId")]
id: str
kind: SubAgentActivityKind
model: Annotated[
str | None,
Field(
description="Resolved model at sub-agent creation; absent from older records and other activities."
),
] = None
reasoning_effort: Annotated[
ReasoningEffort | None,
Field(
alias="reasoningEffort",
description="Resolved reasoning effort at sub-agent creation, when known.",
),
] = None
type: Annotated[Literal["subAgentActivity"], Field(title="SubAgentActivityThreadItemType")]