From b38ff54f0456ee1a9de67f4f56626d93cf504cf5 Mon Sep 17 00:00:00 2001 From: iqdoctor Date: Tue, 26 May 2026 12:29:22 +0000 Subject: [PATCH 1/2] fix(tui): keep subagent labels in collab events --- .../schema/typescript/v2/CollabAgentState.ts | 2 +- .../src/protocol/event_mapping.rs | 4 ++- .../src/protocol/thread_history.rs | 10 ++++++- .../src/protocol/v2/item.rs | 27 +++++++++++++++++++ .../src/protocol/v2/tests.rs | 2 ++ codex-rs/tui/src/app.rs | 17 ++++++++++++ codex-rs/tui/src/app/tests.rs | 2 ++ codex-rs/tui/src/app/thread_routing.rs | 27 +++++++++++++++---- .../tui/src/chatwidget/tests/app_server.rs | 8 ++++++ codex-rs/tui/src/multi_agents.rs | 2 ++ 10 files changed, 93 insertions(+), 8 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts index 785dbf1fe0f..9c71d4ea8ca 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CollabAgentStatus } from "./CollabAgentStatus"; -export type CollabAgentState = { status: CollabAgentStatus, message: string | null, }; +export type CollabAgentState = { status: CollabAgentStatus, message: string | null, agentNickname?: string, agentRole?: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs index 609ca83a5dd..27162c3fdca 100644 --- a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -104,7 +104,9 @@ pub fn item_event_to_server_notification( let (receiver_thread_ids, agents_states) = match end_event.new_thread_id { Some(id) => { let receiver_id = id.to_string(); - let received_status = CollabAgentState::from(end_event.status.clone()); + let mut received_status = CollabAgentState::from(end_event.status.clone()); + received_status.agent_nickname = end_event.new_agent_nickname.clone(); + received_status.agent_role = end_event.new_agent_role.clone(); ( vec![receiver_id.clone()], [(receiver_id, received_status)].into_iter().collect(), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index aa65f9ab9d8..5c729ac7b46 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -640,7 +640,9 @@ impl ThreadHistoryBuilder { let (receiver_thread_ids, agents_states) = match &payload.new_thread_id { Some(id) => { let receiver_id = id.to_string(); - let received_status = CollabAgentState::from(payload.status.clone()); + let mut received_status = CollabAgentState::from(payload.status.clone()); + received_status.agent_nickname = payload.new_agent_nickname.clone(); + received_status.agent_role = payload.new_agent_role.clone(); ( vec![receiver_id.clone()], [(receiver_id, received_status)].into_iter().collect(), @@ -2999,6 +3001,8 @@ mod tests { CollabAgentState { status: crate::protocol::v2::CollabAgentStatus::Completed, message: None, + agent_nickname: None, + agent_role: None, }, )] .into_iter() @@ -3059,6 +3063,8 @@ mod tests { CollabAgentState { status: crate::protocol::v2::CollabAgentStatus::Running, message: None, + agent_nickname: Some("Scout".into()), + agent_role: Some("explorer".into()), }, )] .into_iter() @@ -3131,6 +3137,8 @@ mod tests { CollabAgentState { status: crate::protocol::v2::CollabAgentStatus::Interrupted, message: None, + agent_nickname: None, + agent_role: None, }, )] .into_iter() diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index d68485565ee..d815ea5c41e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -1023,6 +1023,19 @@ pub enum CollabAgentStatus { pub struct CollabAgentState { pub status: CollabAgentStatus, pub message: Option, + /// Optional nickname assigned to an AgentControl-spawned sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde( + default, + alias = "agentType", + alias = "agent_type", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional)] + pub agent_role: Option, } impl From for CollabAgentState { @@ -1031,30 +1044,44 @@ impl From for CollabAgentState { CoreAgentStatus::PendingInit => Self { status: CollabAgentStatus::PendingInit, message: None, + agent_nickname: None, + agent_role: None, }, CoreAgentStatus::Running => Self { status: CollabAgentStatus::Running, message: None, + agent_nickname: None, + agent_role: None, }, CoreAgentStatus::Interrupted => Self { status: CollabAgentStatus::Interrupted, message: None, + agent_nickname: None, + agent_role: None, }, CoreAgentStatus::Completed(message) => Self { status: CollabAgentStatus::Completed, message, + agent_nickname: None, + agent_role: None, }, CoreAgentStatus::Errored(message) => Self { status: CollabAgentStatus::Errored, message: Some(message), + agent_nickname: None, + agent_role: None, }, CoreAgentStatus::Shutdown => Self { status: CollabAgentStatus::Shutdown, message: None, + agent_nickname: None, + agent_role: None, }, CoreAgentStatus::NotFound => Self { status: CollabAgentStatus::NotFound, message: None, + agent_nickname: None, + agent_role: None, }, } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index bb6003c7aef..88015093818 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -276,6 +276,8 @@ fn collab_agent_state_maps_interrupted_status() { CollabAgentState { status: CollabAgentStatus::Interrupted, message: None, + agent_nickname: None, + agent_role: None, } ); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1d6a54b080c..0d8566034f5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -285,6 +285,23 @@ fn collab_receiver_is_not_found( } } +fn collab_receiver_agent_metadata( + notification: &ServerNotification, + receiver_thread_id: &str, +) -> (Option, Option) { + match notification { + ServerNotification::ItemStarted(notification) + | ServerNotification::ItemCompleted(notification) => match ¬ification.item { + ThreadItem::CollabAgentToolCall { agents_states, .. } => agents_states + .get(receiver_thread_id) + .map(|state| (state.agent_nickname.clone(), state.agent_role.clone())) + .unwrap_or_default(), + _ => (None, None), + }, + _ => (None, None), + } +} + fn default_exec_approval_decisions( network_approval_context: Option<&codex_app_server_protocol::NetworkApprovalContext>, proposed_execpolicy_amendment: Option<&codex_app_server_protocol::ExecPolicyAmendment>, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 993772ab774..1bab63b0b9a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1194,6 +1194,8 @@ async fn collab_receiver_notification_does_not_cache_not_found_thread() { codex_app_server_protocol::CollabAgentState { status: codex_app_server_protocol::CollabAgentStatus::NotFound, message: None, + agent_nickname: None, + agent_role: None, }, )]), }, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index a403dc232c5..525765e8ee6 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -912,12 +912,27 @@ impl App { continue; }; - if self.agent_navigation.get(&thread_id).is_some() { + let (agent_nickname, agent_role) = + collab_receiver_agent_metadata(notification, receiver_thread_id); + if let Some(existing) = self.agent_navigation.get(&thread_id) { + if agent_nickname.is_none() && agent_role.is_none() { + continue; + } + let upgraded_nickname = agent_nickname.or_else(|| existing.agent_nickname.clone()); + let upgraded_role = agent_role.or_else(|| existing.agent_role.clone()); + self.upsert_agent_picker_thread( + thread_id, + upgraded_nickname, + upgraded_role, + existing.is_closed, + ); continue; } self.upsert_agent_picker_thread( - thread_id, /*agent_nickname*/ None, /*agent_role*/ None, + thread_id, + agent_nickname, + agent_role, /*is_closed*/ false, ); } @@ -1414,9 +1429,11 @@ impl App { pub(super) fn handle_thread_event_replay(&mut self, event: ThreadBufferedEvent) { match event { - ThreadBufferedEvent::Notification(notification) => self - .chat_widget - .handle_server_notification(notification, Some(ReplayKind::ThreadSnapshot)), + ThreadBufferedEvent::Notification(notification) => { + self.cache_collab_receiver_threads_for_notification(¬ification); + self.chat_widget + .handle_server_notification(notification, Some(ReplayKind::ThreadSnapshot)); + } ThreadBufferedEvent::Request(request) => self .chat_widget .handle_server_request(request, Some(ReplayKind::ThreadSnapshot)), diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index f4fcc85e438..973368e3bcf 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -238,6 +238,8 @@ async fn collab_spawn_end_shows_requested_model_and_effort() { AppServerCollabAgentState { status: AppServerCollabAgentStatus::PendingInit, message: None, + agent_nickname: None, + agent_role: None, }, )]), }, @@ -637,6 +639,8 @@ async fn live_app_server_collab_wait_items_render_history() { AppServerCollabAgentState { status: AppServerCollabAgentStatus::Completed, message: Some("Done".to_string()), + agent_nickname: None, + agent_role: None, }, ), ( @@ -644,6 +648,8 @@ async fn live_app_server_collab_wait_items_render_history() { AppServerCollabAgentState { status: AppServerCollabAgentStatus::Running, message: None, + agent_nickname: None, + agent_role: None, }, ), ]), @@ -707,6 +713,8 @@ async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effo AppServerCollabAgentState { status: AppServerCollabAgentStatus::PendingInit, message: None, + agent_nickname: None, + agent_role: None, }, )]), }, diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 3273c610ae2..559792a95c2 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -854,6 +854,8 @@ mod tests { CollabAgentState { status, message: message.map(str::to_string), + agent_nickname: None, + agent_role: None, } } From aa907ec21984aba3713a6a0f400dd1a04c1b3556 Mon Sep 17 00:00:00 2001 From: vp Date: Sat, 30 May 2026 01:16:50 +0300 Subject: [PATCH 2/2] fix(tui): preserve subagent labels in collab history --- .../schema/json/ServerNotification.json | 14 + .../codex_app_server_protocol.schemas.json | 14 + .../codex_app_server_protocol.v2.schemas.json | 14 + .../json/v2/ItemCompletedNotification.json | 14 + .../json/v2/ItemStartedNotification.json | 14 + .../schema/json/v2/ReviewStartResponse.json | 14 + .../schema/json/v2/ThreadForkResponse.json | 14 + .../schema/json/v2/ThreadListResponse.json | 14 + .../json/v2/ThreadMetadataUpdateResponse.json | 14 + .../schema/json/v2/ThreadReadResponse.json | 14 + .../schema/json/v2/ThreadResumeResponse.json | 14 + .../json/v2/ThreadRollbackResponse.json | 14 + .../schema/json/v2/ThreadStartResponse.json | 14 + .../json/v2/ThreadStartedNotification.json | 14 + .../json/v2/ThreadUnarchiveResponse.json | 14 + .../json/v2/TurnCompletedNotification.json | 14 + .../schema/json/v2/TurnStartResponse.json | 14 + .../json/v2/TurnStartedNotification.json | 14 + .../schema/typescript/v2/CollabAgentState.ts | 10 +- .../src/protocol/event_mapping.rs | 372 ++++++++++++++++-- .../src/protocol/thread_history.rs | 268 +++++++++++-- codex-rs/tui/src/app.rs | 19 +- 22 files changed, 860 insertions(+), 61 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b0501b0e9ef..6020d1a9283 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -692,6 +692,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0c2ebf121e8..4e572c76d0a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -6693,6 +6693,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c6ea51680d8..9164e1ec22b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3062,6 +3062,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 8ba25f9a07d..15343f7ae01 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -26,6 +26,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 70fc43f7b8a..bfeadd4537c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -26,6 +26,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index a644ce8c4e6..a7fec1c7d92 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -163,6 +163,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index ec46130357c..666a64846ea 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -245,6 +245,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 5ace3f7af76..cdade1a0309 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -166,6 +166,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index a4cc7d91ff7..32a33115d31 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -166,6 +166,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 234e0867f88..3c137c919df 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -166,6 +166,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 59ed236c480..45da03423fe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -245,6 +245,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 7815f261c0a..b0b01476703 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -166,6 +166,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index ca52f10ca53..64b39b02fd4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -245,6 +245,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index f6cb72b6b20..3bd2389dc1c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -166,6 +166,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index e4e317e608b..ca485c56891 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -166,6 +166,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 6c64eb32753..1a26ed93c3f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -163,6 +163,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 5fe97545762..445a973469e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -163,6 +163,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 1db14972182..2c4cdc1ad75 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -163,6 +163,20 @@ }, "CollabAgentState": { "properties": { + "agentNickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, "message": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts index 9c71d4ea8ca..b5b897dbc2b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentState.ts @@ -3,4 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CollabAgentStatus } from "./CollabAgentStatus"; -export type CollabAgentState = { status: CollabAgentStatus, message: string | null, agentNickname?: string, agentRole?: string, }; +export type CollabAgentState = { status: CollabAgentStatus, message: string | null, +/** + * Optional nickname assigned to an AgentControl-spawned sub-agent. + */ +agentNickname?: string, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agentRole?: string, }; diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs index 27162c3fdca..5bc97fba3dc 100644 --- a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -22,6 +22,21 @@ use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynam use codex_protocol::protocol::EventMsg; use std::collections::HashMap; +fn collab_agent_state_with_metadata( + status: codex_protocol::protocol::AgentStatus, + agent_nickname: Option, + agent_role: Option, +) -> CollabAgentState { + let mut state = CollabAgentState::from(status); + state.agent_nickname = agent_nickname; + state.agent_role = agent_role; + state +} + +fn collab_agent_has_metadata(agent_nickname: &Option, agent_role: &Option) -> bool { + agent_nickname.is_some() || agent_role.is_some() +} + /// Build the v2 app-server notification that directly corresponds to a single core event. /// /// This only covers the stateless event-to-notification projections that have a one-to-one @@ -104,9 +119,11 @@ pub fn item_event_to_server_notification( let (receiver_thread_ids, agents_states) = match end_event.new_thread_id { Some(id) => { let receiver_id = id.to_string(); - let mut received_status = CollabAgentState::from(end_event.status.clone()); - received_status.agent_nickname = end_event.new_agent_nickname.clone(); - received_status.agent_role = end_event.new_agent_role.clone(); + let received_status = collab_agent_state_with_metadata( + end_event.status.clone(), + end_event.new_agent_nickname.clone(), + end_event.new_agent_role.clone(), + ); ( vec![receiver_id.clone()], [(receiver_id, received_status)].into_iter().collect(), @@ -161,7 +178,11 @@ pub fn item_event_to_server_notification( _ => CollabAgentToolCallStatus::Completed, }; let receiver_id = end_event.receiver_thread_id.to_string(); - let received_status = CollabAgentState::from(end_event.status); + let received_status = collab_agent_state_with_metadata( + end_event.status, + end_event.receiver_agent_nickname, + end_event.receiver_agent_role, + ); let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::SendInput, @@ -186,6 +207,21 @@ pub fn item_event_to_server_notification( .iter() .map(ToString::to_string) .collect(); + let agents_states = begin_event + .receiver_agents + .iter() + .filter(|agent| collab_agent_has_metadata(&agent.agent_nickname, &agent.agent_role)) + .map(|agent| { + ( + agent.thread_id.to_string(), + collab_agent_state_with_metadata( + codex_protocol::protocol::AgentStatus::PendingInit, + agent.agent_nickname.clone(), + agent.agent_role.clone(), + ), + ) + }) + .collect(); let item = ThreadItem::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::Wait, @@ -195,7 +231,7 @@ pub fn item_event_to_server_notification( prompt: None, model: None, reasoning_effort: None, - agents_states: HashMap::new(), + agents_states, }; ServerNotification::ItemStarted(ItemStartedNotification { thread_id, @@ -205,23 +241,65 @@ pub fn item_event_to_server_notification( }) } EventMsg::CollabWaitingEnd(end_event) => { - let status = if end_event.statuses.values().any(|status| { - matches!( - status, - codex_protocol::protocol::AgentStatus::Errored(_) - | codex_protocol::protocol::AgentStatus::NotFound - ) - }) { - CollabAgentToolCallStatus::Failed - } else { - CollabAgentToolCallStatus::Completed - }; - let receiver_thread_ids = end_event.statuses.keys().map(ToString::to_string).collect(); - let agents_states = end_event - .statuses - .iter() - .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) - .collect(); + let (receiver_thread_ids, agents_states, status) = + if end_event.agent_statuses.is_empty() { + let status = if end_event.statuses.values().any(|status| { + matches!( + status, + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound + ) + }) { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + }; + ( + end_event.statuses.keys().map(ToString::to_string).collect(), + end_event + .statuses + .iter() + .map(|(id, status)| { + (id.to_string(), CollabAgentState::from(status.clone())) + }) + .collect(), + status, + ) + } else { + let status = if end_event.agent_statuses.iter().any(|entry| { + matches!( + entry.status, + codex_protocol::protocol::AgentStatus::Errored(_) + | codex_protocol::protocol::AgentStatus::NotFound + ) + }) { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + }; + ( + end_event + .agent_statuses + .iter() + .map(|entry| entry.thread_id.to_string()) + .collect(), + end_event + .agent_statuses + .iter() + .map(|entry| { + ( + entry.thread_id.to_string(), + collab_agent_state_with_metadata( + entry.status.clone(), + entry.agent_nickname.clone(), + entry.agent_role.clone(), + ), + ) + }) + .collect(), + status, + ) + }; let item = ThreadItem::CollabAgentToolCall { id: end_event.call_id, tool: CollabAgentTool::Wait, @@ -270,7 +348,11 @@ pub fn item_event_to_server_notification( let receiver_id = end_event.receiver_thread_id.to_string(); let agents_states = [( receiver_id.clone(), - CollabAgentState::from(end_event.status), + collab_agent_state_with_metadata( + end_event.status, + end_event.receiver_agent_nickname, + end_event.receiver_agent_role, + ), )] .into_iter() .collect(); @@ -293,16 +375,33 @@ pub fn item_event_to_server_notification( }) } EventMsg::CollabResumeBegin(begin_event) => { + let receiver_id = begin_event.receiver_thread_id.to_string(); + let agents_states = match ( + begin_event.receiver_agent_nickname, + begin_event.receiver_agent_role, + ) { + (None, None) => HashMap::new(), + (agent_nickname, agent_role) => [( + receiver_id.clone(), + collab_agent_state_with_metadata( + codex_protocol::protocol::AgentStatus::PendingInit, + agent_nickname, + agent_role, + ), + )] + .into_iter() + .collect(), + }; let item = ThreadItem::CollabAgentToolCall { id: begin_event.call_id, tool: CollabAgentTool::ResumeAgent, status: CollabAgentToolCallStatus::InProgress, sender_thread_id: begin_event.sender_thread_id.to_string(), - receiver_thread_ids: vec![begin_event.receiver_thread_id.to_string()], + receiver_thread_ids: vec![receiver_id], prompt: None, model: None, reasoning_effort: None, - agents_states: HashMap::new(), + agents_states, }; ServerNotification::ItemStarted(ItemStartedNotification { thread_id, @@ -322,7 +421,11 @@ pub fn item_event_to_server_notification( let receiver_id = end_event.receiver_thread_id.to_string(); let agents_states = [( receiver_id.clone(), - CollabAgentState::from(end_event.status), + collab_agent_state_with_metadata( + end_event.status, + end_event.receiver_agent_nickname, + end_event.receiver_agent_role, + ), )] .into_iter() .collect(); @@ -454,9 +557,14 @@ pub fn item_event_to_server_notification( #[cfg(test)] mod tests { use super::*; + use crate::protocol::v2::CollabAgentStatus; use codex_protocol::ThreadId; + use codex_protocol::protocol::AgentStatus; + use codex_protocol::protocol::CollabAgentRef; + use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; + use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecOutputStream; use pretty_assertions::assert_eq; @@ -565,7 +673,217 @@ mod tests { reasoning_effort: None, agents_states: [( receiver_id, - CollabAgentState::from(codex_protocol::protocol::AgentStatus::NotFound), + CollabAgentState { + status: CollabAgentStatus::NotFound, + message: None, + agent_nickname: None, + agent_role: None, + }, + )] + .into_iter() + .collect(), + }, + }, + ); + } + + #[test] + fn collab_waiting_end_prefers_metadata_bearing_agent_statuses() { + let sender = ThreadId::new(); + let receiver = ThreadId::new(); + let event = CollabWaitingEndEvent { + sender_thread_id: sender, + call_id: "wait-1".to_string(), + completed_at_ms: 789, + agent_statuses: vec![CollabAgentStatusEntry { + thread_id: receiver, + agent_nickname: Some("Hume".to_string()), + agent_role: Some("code-reviewer".to_string()), + status: AgentStatus::Completed(Some("done".to_string())), + }], + statuses: [(receiver, AgentStatus::Completed(Some("legacy".to_string())))] + .into_iter() + .collect(), + }; + + let notification = item_event_to_server_notification( + EventMsg::CollabWaitingEnd(event.clone()), + "thread-wait", + "turn-wait", + ); + let receiver_id = receiver.to_string(); + assert_item_completed_server_notification( + notification, + ItemCompletedNotification { + thread_id: "thread-wait".to_string(), + turn_id: "turn-wait".to_string(), + completed_at_ms: event.completed_at_ms, + item: ThreadItem::CollabAgentToolCall { + id: "wait-1".to_string(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: sender.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [( + receiver_id, + CollabAgentState { + status: CollabAgentStatus::Completed, + message: Some("done".to_string()), + agent_nickname: Some("Hume".to_string()), + agent_role: Some("code-reviewer".to_string()), + }, + )] + .into_iter() + .collect(), + }, + }, + ); + } + + #[test] + fn collab_waiting_begin_maps_receiver_metadata_to_pending_init_state() { + let sender = ThreadId::new(); + let receiver = ThreadId::new(); + let event = codex_protocol::protocol::CollabWaitingBeginEvent { + started_at_ms: 123, + sender_thread_id: sender, + receiver_thread_ids: vec![receiver], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver, + agent_nickname: Some("Hilbert".to_string()), + agent_role: Some("architect".to_string()), + }], + call_id: "wait-begin".to_string(), + }; + + let notification = item_event_to_server_notification( + EventMsg::CollabWaitingBegin(event.clone()), + "thread-wait", + "turn-wait", + ); + let receiver_id = receiver.to_string(); + assert_item_started_server_notification( + notification, + ItemStartedNotification { + thread_id: "thread-wait".to_string(), + turn_id: "turn-wait".to_string(), + started_at_ms: event.started_at_ms, + item: ThreadItem::CollabAgentToolCall { + id: "wait-begin".to_string(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: sender.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [( + receiver_id, + CollabAgentState { + status: CollabAgentStatus::PendingInit, + message: None, + agent_nickname: Some("Hilbert".to_string()), + agent_role: Some("architect".to_string()), + }, + )] + .into_iter() + .collect(), + }, + }, + ); + } + + #[test] + fn collab_waiting_begin_without_receiver_metadata_keeps_empty_agent_states() { + let sender = ThreadId::new(); + let receiver = ThreadId::new(); + let event = codex_protocol::protocol::CollabWaitingBeginEvent { + started_at_ms: 123, + sender_thread_id: sender, + receiver_thread_ids: vec![receiver], + receiver_agents: vec![CollabAgentRef { + thread_id: receiver, + agent_nickname: None, + agent_role: None, + }], + call_id: "wait-begin".to_string(), + }; + + let notification = item_event_to_server_notification( + EventMsg::CollabWaitingBegin(event.clone()), + "thread-wait", + "turn-wait", + ); + assert_item_started_server_notification( + notification, + ItemStartedNotification { + thread_id: "thread-wait".to_string(), + turn_id: "turn-wait".to_string(), + started_at_ms: event.started_at_ms, + item: ThreadItem::CollabAgentToolCall { + id: "wait-begin".to_string(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: sender.to_string(), + receiver_thread_ids: vec![receiver.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }, + }, + ); + } + + #[test] + fn collab_waiting_end_agent_statuses_drive_failure_even_when_legacy_statuses_empty() { + let sender = ThreadId::new(); + let receiver = ThreadId::new(); + let event = CollabWaitingEndEvent { + sender_thread_id: sender, + call_id: "wait-1".to_string(), + completed_at_ms: 789, + agent_statuses: vec![CollabAgentStatusEntry { + thread_id: receiver, + agent_nickname: Some("Noether".to_string()), + agent_role: Some("verifier".to_string()), + status: AgentStatus::NotFound, + }], + statuses: HashMap::new(), + }; + + let notification = item_event_to_server_notification( + EventMsg::CollabWaitingEnd(event.clone()), + "thread-wait", + "turn-wait", + ); + let receiver_id = receiver.to_string(); + assert_item_completed_server_notification( + notification, + ItemCompletedNotification { + thread_id: "thread-wait".to_string(), + turn_id: "turn-wait".to_string(), + completed_at_ms: event.completed_at_ms, + item: ThreadItem::CollabAgentToolCall { + id: "wait-1".to_string(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Failed, + sender_thread_id: sender.to_string(), + receiver_thread_ids: vec![receiver_id.clone()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [( + receiver_id, + CollabAgentState { + status: CollabAgentStatus::NotFound, + message: None, + agent_nickname: Some("Noether".to_string()), + agent_role: Some("verifier".to_string()), + }, )] .into_iter() .collect(), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 5c729ac7b46..497d0c4e27c 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -58,6 +58,21 @@ use std::collections::HashMap; use tracing::warn; use uuid::Uuid; +fn collab_agent_state_with_metadata( + status: AgentStatus, + agent_nickname: Option, + agent_role: Option, +) -> CollabAgentState { + let mut state = CollabAgentState::from(status); + state.agent_nickname = agent_nickname; + state.agent_role = agent_role; + state +} + +fn collab_agent_has_metadata(agent_nickname: &Option, agent_role: &Option) -> bool { + agent_nickname.is_some() || agent_role.is_some() +} + #[cfg(test)] use crate::protocol::v2::CommandAction; #[cfg(test)] @@ -640,9 +655,11 @@ impl ThreadHistoryBuilder { let (receiver_thread_ids, agents_states) = match &payload.new_thread_id { Some(id) => { let receiver_id = id.to_string(); - let mut received_status = CollabAgentState::from(payload.status.clone()); - received_status.agent_nickname = payload.new_agent_nickname.clone(); - received_status.agent_role = payload.new_agent_role.clone(); + let received_status = collab_agent_state_with_metadata( + payload.status.clone(), + payload.new_agent_nickname.clone(), + payload.new_agent_role.clone(), + ); ( vec![receiver_id.clone()], [(receiver_id, received_status)].into_iter().collect(), @@ -690,7 +707,11 @@ impl ThreadHistoryBuilder { _ => CollabAgentToolCallStatus::Completed, }; let receiver_id = payload.receiver_thread_id.to_string(); - let received_status = CollabAgentState::from(payload.status.clone()); + let received_status = collab_agent_state_with_metadata( + payload.status.clone(), + payload.receiver_agent_nickname.clone(), + payload.receiver_agent_role.clone(), + ); self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::SendInput, @@ -721,7 +742,21 @@ impl ThreadHistoryBuilder { prompt: None, model: None, reasoning_effort: None, - agents_states: HashMap::new(), + agents_states: payload + .receiver_agents + .iter() + .filter(|agent| collab_agent_has_metadata(&agent.agent_nickname, &agent.agent_role)) + .map(|agent| { + ( + agent.thread_id.to_string(), + collab_agent_state_with_metadata( + AgentStatus::PendingInit, + agent.agent_nickname.clone(), + agent.agent_role.clone(), + ), + ) + }) + .collect(), }; self.upsert_item_in_current_turn(item); } @@ -730,23 +765,62 @@ impl ThreadHistoryBuilder { &mut self, payload: &codex_protocol::protocol::CollabWaitingEndEvent, ) { - let status = if payload - .statuses - .values() - .any(|status| matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound)) - { - CollabAgentToolCallStatus::Failed - } else { - CollabAgentToolCallStatus::Completed - }; - let mut receiver_thread_ids: Vec = - payload.statuses.keys().map(ToString::to_string).collect(); + let (mut receiver_thread_ids, agents_states, status): (Vec, HashMap<_, _>, _) = + if payload.agent_statuses.is_empty() { + let status = + if payload.statuses.values().any(|status| { + matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound) + }) { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + }; + ( + payload.statuses.keys().map(ToString::to_string).collect(), + payload + .statuses + .iter() + .map(|(id, status)| { + (id.to_string(), CollabAgentState::from(status.clone())) + }) + .collect(), + status, + ) + } else { + let status = if payload.agent_statuses.iter().any(|entry| { + matches!( + entry.status, + AgentStatus::Errored(_) | AgentStatus::NotFound + ) + }) { + CollabAgentToolCallStatus::Failed + } else { + CollabAgentToolCallStatus::Completed + }; + ( + payload + .agent_statuses + .iter() + .map(|entry| entry.thread_id.to_string()) + .collect(), + payload + .agent_statuses + .iter() + .map(|entry| { + ( + entry.thread_id.to_string(), + collab_agent_state_with_metadata( + entry.status.clone(), + entry.agent_nickname.clone(), + entry.agent_role.clone(), + ), + ) + }) + .collect(), + status, + ) + }; receiver_thread_ids.sort(); - let agents_states = payload - .statuses - .iter() - .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) - .collect(); self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall { id: payload.call_id.clone(), tool: CollabAgentTool::Wait, @@ -786,7 +860,11 @@ impl ThreadHistoryBuilder { let receiver_id = payload.receiver_thread_id.to_string(); let agents_states = [( receiver_id.clone(), - CollabAgentState::from(payload.status.clone()), + collab_agent_state_with_metadata( + payload.status.clone(), + payload.receiver_agent_nickname.clone(), + payload.receiver_agent_role.clone(), + ), )] .into_iter() .collect(); @@ -816,7 +894,22 @@ impl ThreadHistoryBuilder { prompt: None, model: None, reasoning_effort: None, - agents_states: HashMap::new(), + agents_states: match ( + payload.receiver_agent_nickname.clone(), + payload.receiver_agent_role.clone(), + ) { + (None, None) => HashMap::new(), + (agent_nickname, agent_role) => [( + payload.receiver_thread_id.to_string(), + collab_agent_state_with_metadata( + AgentStatus::PendingInit, + agent_nickname, + agent_role, + ), + )] + .into_iter() + .collect(), + }, }; self.upsert_item_in_current_turn(item); } @@ -832,7 +925,11 @@ impl ThreadHistoryBuilder { let receiver_id = payload.receiver_thread_id.to_string(); let agents_states = [( receiver_id.clone(), - CollabAgentState::from(payload.status.clone()), + collab_agent_state_with_metadata( + payload.status.clone(), + payload.receiver_agent_nickname.clone(), + payload.receiver_agent_role.clone(), + ), )] .into_iter() .collect(); @@ -1226,6 +1323,7 @@ mod tests { use codex_protocol::protocol::AgentReasoningRawContentEvent; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::CodexErrorInfo; + use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::DynamicToolCallResponseEvent; use codex_protocol::protocol::ExecCommandEndEvent; @@ -3147,6 +3245,128 @@ mod tests { ); } + #[test] + fn reconstructs_collab_send_input_end_with_receiver_metadata() { + 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(), + ..Default::default() + }), + EventMsg::CollabAgentInteractionEnd( + codex_protocol::protocol::CollabAgentInteractionEndEvent { + call_id: "send-1".into(), + completed_at_ms: 0, + sender_thread_id: sender, + receiver_thread_id: receiver, + receiver_agent_nickname: Some("Turing".into()), + receiver_agent_role: Some("executor".into()), + prompt: "new task".into(), + status: AgentStatus::Completed(Some("done".into())), + }, + ), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + 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::Completed, + message: Some("done".into()), + agent_nickname: Some("Turing".into()), + agent_role: Some("executor".into()), + }, + )] + .into_iter() + .collect(), + } + ); + } + + #[test] + fn reconstructs_collab_wait_end_from_agent_statuses_when_legacy_statuses_empty() { + 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: "wait".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + ..Default::default() + }), + EventMsg::CollabWaitingEnd(codex_protocol::protocol::CollabWaitingEndEvent { + sender_thread_id: sender, + call_id: "wait-1".into(), + completed_at_ms: 0, + agent_statuses: vec![CollabAgentStatusEntry { + thread_id: receiver, + agent_nickname: Some("Noether".into()), + agent_role: Some("verifier".into()), + status: AgentStatus::NotFound, + }], + statuses: HashMap::new(), + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + 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: "wait-1".into(), + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Failed, + sender_thread_id: sender.to_string(), + receiver_thread_ids: vec![receiver.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: [( + receiver.to_string(), + CollabAgentState { + status: crate::protocol::v2::CollabAgentStatus::NotFound, + message: None, + agent_nickname: Some("Noether".into()), + agent_role: Some("verifier".into()), + }, + )] + .into_iter() + .collect(), + } + ); + } + #[test] fn rollback_failed_error_does_not_mark_turn_failed() { let events = vec![ diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0d8566034f5..cc49d276811 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -289,15 +289,16 @@ fn collab_receiver_agent_metadata( notification: &ServerNotification, receiver_thread_id: &str, ) -> (Option, Option) { - match notification { - ServerNotification::ItemStarted(notification) - | ServerNotification::ItemCompleted(notification) => match ¬ification.item { - ThreadItem::CollabAgentToolCall { agents_states, .. } => agents_states - .get(receiver_thread_id) - .map(|state| (state.agent_nickname.clone(), state.agent_role.clone())) - .unwrap_or_default(), - _ => (None, None), - }, + let item = match notification { + ServerNotification::ItemStarted(notification) => ¬ification.item, + ServerNotification::ItemCompleted(notification) => ¬ification.item, + _ => return (None, None), + }; + match item { + ThreadItem::CollabAgentToolCall { agents_states, .. } => agents_states + .get(receiver_thread_id) + .map(|state| (state.agent_nickname.clone(), state.agent_role.clone())) + .unwrap_or_default(), _ => (None, None), } }