From 4d60c803ba9c703b978e89a8f6f8a4ad1866582c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 20 Feb 2026 15:26:33 +0000 Subject: [PATCH] feat: cleaner TUI for sub-agents (#12327) Screenshot 2026-02-20 at 14 31 25 --- .../schema/json/EventMsg.json | 237 +++++++ .../schema/json/ServerNotification.json | 153 +++++ .../codex_app_server_protocol.schemas.json | 153 +++++ .../json/v1/ForkConversationResponse.json | 153 +++++ .../json/v1/ResumeConversationResponse.json | 153 +++++ .../v1/SessionConfiguredNotification.json | 153 +++++ .../CollabAgentInteractionEndEvent.ts | 8 + .../schema/typescript/CollabAgentRef.ts | 18 + .../typescript/CollabAgentSpawnEndEvent.ts | 8 + .../typescript/CollabAgentStatusEntry.ts | 23 + .../schema/typescript/CollabCloseEndEvent.ts | 8 + .../typescript/CollabResumeBeginEvent.ts | 10 +- .../schema/typescript/CollabResumeEndEvent.ts | 8 + .../typescript/CollabWaitingBeginEvent.ts | 5 + .../typescript/CollabWaitingEndEvent.ts | 5 + .../schema/typescript/index.ts | 2 + codex-rs/app-server-protocol/src/export.rs | 13 +- .../src/protocol/thread_history.rs | 2 + .../app-server/src/bespoke_event_handling.rs | 4 + codex-rs/core/src/agent/control.rs | 17 + .../core/src/tools/handlers/multi_agents.rs | 106 ++++ .../src/event_processor_with_human_output.rs | 5 + .../tests/event_processor_with_json_output.rs | 3 + codex-rs/protocol/src/protocol.rs | 62 ++ codex-rs/tui/src/multi_agents.rs | 585 ++++++++++++------ ...gents__tests__collab_agent_transcript.snap | 17 + 26 files changed, 1735 insertions(+), 176 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts create mode 100644 codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index a85d02853..33f58d18b 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7528,6 +7681,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -7633,6 +7800,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7683,6 +7864,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -7718,6 +7906,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -7801,6 +7996,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7850,6 +8059,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -7890,6 +8113,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 128f4fa60..6a6bf2d7c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -808,6 +808,36 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, "CollabAgentState": { "properties": { "message": { @@ -836,6 +866,45 @@ ], "type": "string" }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "CollabAgentTool": { "enum": [ "spawnAgent", @@ -3364,6 +3433,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -3469,6 +3552,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -3519,6 +3616,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -3554,6 +3658,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -3637,6 +3748,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -3686,6 +3811,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -3726,6 +3865,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { 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 ea4cd0bd3..df73bf2c4 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 @@ -2029,6 +2029,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "CommandExecutionApprovalDecision": { "oneOf": [ { @@ -4592,6 +4661,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -4697,6 +4780,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -4747,6 +4844,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -4782,6 +4886,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -4865,6 +4976,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -4914,6 +5039,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -4954,6 +5093,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index d7f1e1d83..f88d5f741 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index 9cbd492d9..e619dfe12 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index fd66f6060..61887b400 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -296,6 +296,75 @@ } ] }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, "ContentItem": { "oneOf": [ { @@ -2548,6 +2617,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, "new_thread_id": { "anyOf": [ { @@ -2653,6 +2736,20 @@ "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2703,6 +2800,13 @@ "description": "ID of the waiting call.", "type": "string" }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, "receiver_thread_ids": { "description": "Thread ID of the receivers.", "items": { @@ -2738,6 +2842,13 @@ { "description": "Collab interaction: waiting end.", "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, "call_id": { "description": "ID of the waiting call.", "type": "string" @@ -2821,6 +2932,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2870,6 +2995,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { @@ -2910,6 +3049,20 @@ "description": "Identifier for the collab tool call.", "type": "string" }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, "receiver_thread_id": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts index 0596300b3..5458e06dc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentInteractionEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the receiver. */ receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, /** * Prompt sent from the sender to the receiver. Can be empty to prevent CoT * leaking at the beginning. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts new file mode 100644 index 000000000..cae7bf88b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentRef.ts @@ -0,0 +1,18 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadId } from "./ThreadId"; + +export type CollabAgentRef = { +/** + * Thread ID of the receiver/new agent. + */ +thread_id: ThreadId, +/** + * Optional nickname assigned to an AgentControl-spawned sub-agent. + */ +agent_nickname?: string | null, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agent_role?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts index e880b5a40..34753c8e0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentSpawnEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the newly spawned agent, if it was created. */ new_thread_id: ThreadId | null, +/** + * Optional nickname assigned to the new agent. + */ +new_agent_nickname?: string | null, +/** + * Optional role assigned to the new agent. + */ +new_agent_role?: string | null, /** * Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the * beginning. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts b/codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts new file mode 100644 index 000000000..286d19423 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/CollabAgentStatusEntry.ts @@ -0,0 +1,23 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentStatus } from "./AgentStatus"; +import type { ThreadId } from "./ThreadId"; + +export type CollabAgentStatusEntry = { +/** + * Thread ID of the receiver/new agent. + */ +thread_id: ThreadId, +/** + * Optional nickname assigned to an AgentControl-spawned sub-agent. + */ +agent_nickname?: string | null, +/** + * Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + */ +agent_role?: string | null, +/** + * Last known status of the agent. + */ +status: AgentStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts index 70343cbe4..171886f1e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabCloseEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the receiver. */ receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, /** * Last known status of the receiver agent reported to the sender agent before * the close. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts index b70366672..e6c1c3d5c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabResumeBeginEvent.ts @@ -15,4 +15,12 @@ sender_thread_id: ThreadId, /** * Thread ID of the receiver. */ -receiver_thread_id: ThreadId, }; +receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts index 9eed31036..caf970ec2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabResumeEndEvent.ts @@ -17,6 +17,14 @@ sender_thread_id: ThreadId, * Thread ID of the receiver. */ receiver_thread_id: ThreadId, +/** + * Optional nickname assigned to the receiver agent. + */ +receiver_agent_nickname?: string | null, +/** + * Optional role assigned to the receiver agent. + */ +receiver_agent_role?: string | null, /** * Last known status of the receiver agent reported to the sender agent after * resume. diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts index 0cbe04f62..f2f07f87e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingBeginEvent.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CollabAgentRef } from "./CollabAgentRef"; import type { ThreadId } from "./ThreadId"; export type CollabWaitingBeginEvent = { @@ -12,6 +13,10 @@ sender_thread_id: ThreadId, * Thread ID of the receivers. */ receiver_thread_ids: Array, +/** + * Optional nicknames/roles for receivers. + */ +receiver_agents?: Array, /** * ID of the waiting call. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts index 57f914c13..929d59c61 100644 --- a/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/CollabWaitingEndEvent.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AgentStatus } from "./AgentStatus"; +import type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry"; import type { ThreadId } from "./ThreadId"; export type CollabWaitingEndEvent = { @@ -13,6 +14,10 @@ sender_thread_id: ThreadId, * ID of the waiting call. */ call_id: string, +/** + * Optional receiver metadata paired with final statuses. + */ +agent_statuses?: Array, /** * Last known status of the receiver agents reported to the sender agent. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 79d8bdd98..cda04def8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -33,8 +33,10 @@ export type { ClientRequest } from "./ClientRequest"; export type { CodexErrorInfo } from "./CodexErrorInfo"; export type { CollabAgentInteractionBeginEvent } from "./CollabAgentInteractionBeginEvent"; export type { CollabAgentInteractionEndEvent } from "./CollabAgentInteractionEndEvent"; +export type { CollabAgentRef } from "./CollabAgentRef"; export type { CollabAgentSpawnBeginEvent } from "./CollabAgentSpawnBeginEvent"; export type { CollabAgentSpawnEndEvent } from "./CollabAgentSpawnEndEvent"; +export type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry"; export type { CollabCloseBeginEvent } from "./CollabCloseBeginEvent"; export type { CollabCloseEndEvent } from "./CollabCloseEndEvent"; export type { CollabResumeBeginEvent } from "./CollabResumeBeginEvent"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 2cb3f9d1f..ec51dd158 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -1475,7 +1475,18 @@ mod tests { .file_stem() .and_then(|stem| stem.to_str()) .is_some_and(|stem| { - stem.ends_with("Params") || stem == "InitializeCapabilities" + stem.ends_with("Params") + || stem == "InitializeCapabilities" + || matches!( + stem, + "CollabAgentRef" + | "CollabAgentStatusEntry" + | "CollabAgentSpawnEndEvent" + | "CollabAgentInteractionEndEvent" + | "CollabCloseEndEvent" + | "CollabResumeBeginEvent" + | "CollabResumeEndEvent" + ) }); let contents = fs::read_to_string(&path)?; 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 52850a67e..fce6f4f26 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2002,6 +2002,8 @@ mod tests { .expect("valid sender thread id"), receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") .expect("valid receiver thread id"), + receiver_agent_nickname: None, + receiver_agent_role: None, status: AgentStatus::Completed(None), }), ]; diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2e87a1684..ea4fa691d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2059,6 +2059,8 @@ mod tests { call_id: "call-1".to_string(), sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, }; let item = collab_resume_begin_item(event.clone()); @@ -2080,6 +2082,8 @@ mod tests { call_id: "call-2".to_string(), sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), + receiver_agent_nickname: None, + receiver_agent_role: None, status: codex_protocol::protocol::AgentStatus::NotFound, }; diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a9ddde632..7287d2528 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -218,6 +218,23 @@ impl AgentControl { thread.agent_status().await } + pub(crate) async fn get_agent_nickname_and_role( + &self, + agent_id: ThreadId, + ) -> Option<(Option, Option)> { + let Ok(state) = self.upgrade() else { + return None; + }; + let Ok(thread) = state.get_thread(agent_id).await else { + return None; + }; + let session_source = thread.config_snapshot().await.session_source; + Some(( + session_source.get_nickname(), + session_source.get_agent_role(), + )) + } + /// Subscribe to status updates for `agent_id`, yielding the latest value and changes. pub(crate) async fn subscribe_status( &self, diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index dfc82ce52..b0c4cc412 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -20,8 +20,10 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabResumeBeginEvent; @@ -33,6 +35,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; +use std::collections::HashMap; pub struct MultiAgentHandler; @@ -171,6 +174,15 @@ mod spawn { ), Err(_) => (None, AgentStatus::NotFound), }; + let (new_agent_nickname, new_agent_role) = match new_thread_id { + Some(thread_id) => session + .services + .agent_control + .get_agent_nickname_and_role(thread_id) + .await + .unwrap_or((None, None)), + None => (None, None), + }; session .send_event( &turn, @@ -178,6 +190,8 @@ mod spawn { call_id, sender_thread_id: session.conversation_id, new_thread_id, + new_agent_nickname, + new_agent_role, prompt, status, } @@ -228,6 +242,12 @@ mod send_input { let receiver_thread_id = agent_id(&args.id)?; let input_items = parse_collab_input(args.message, args.items)?; let prompt = input_preview(&input_items); + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); if args.interrupt { session .services @@ -266,6 +286,8 @@ mod send_input { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, prompt, status, } @@ -308,6 +330,12 @@ mod resume_agent { ) -> Result { let args: ResumeAgentArgs = parse_arguments(&arguments)?; let receiver_thread_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); let child_depth = next_thread_spawn_depth(&turn.session_source); if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) { return Err(FunctionCallError::RespondToModel( @@ -322,6 +350,8 @@ mod resume_agent { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), } .into(), ) @@ -352,6 +382,12 @@ mod resume_agent { None }; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((receiver_agent_nickname, receiver_agent_role)); session .send_event( &turn, @@ -359,6 +395,8 @@ mod resume_agent { call_id, sender_thread_id: session.conversation_id, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, status: status.clone(), } .into(), @@ -448,6 +486,20 @@ pub(crate) mod wait { .iter() .map(|id| agent_id(id)) .collect::, _>>()?; + let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + for receiver_thread_id in &receiver_thread_ids { + let (agent_nickname, agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(*receiver_thread_id) + .await + .unwrap_or((None, None)); + receiver_agents.push(CollabAgentRef { + thread_id: *receiver_thread_id, + agent_nickname, + agent_role, + }); + } // Validate timeout. // Very short timeouts encourage busy-polling loops in the orchestrator prompt and can @@ -468,6 +520,7 @@ pub(crate) mod wait { CollabWaitingBeginEvent { sender_thread_id: session.conversation_id, receiver_thread_ids: receiver_thread_ids.clone(), + receiver_agents: receiver_agents.clone(), call_id: call_id.clone(), } .into(), @@ -497,6 +550,10 @@ pub(crate) mod wait { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id: call_id.clone(), + agent_statuses: build_wait_agent_statuses( + &statuses, + &receiver_agents, + ), statuses, } .into(), @@ -543,6 +600,7 @@ pub(crate) mod wait { // Convert payload. let statuses_map = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_map, &receiver_agents); let result = WaitResult { status: statuses_map.clone(), timed_out: statuses.is_empty(), @@ -555,6 +613,7 @@ pub(crate) mod wait { CollabWaitingEndEvent { sender_thread_id: session.conversation_id, call_id, + agent_statuses, statuses: statuses_map, } .into(), @@ -611,6 +670,12 @@ pub mod close_agent { ) -> Result { let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(agent_id) + .await + .unwrap_or((None, None)); session .send_event( &turn, @@ -638,6 +703,8 @@ pub mod close_agent { call_id: call_id.clone(), sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), status, } .into(), @@ -664,6 +731,8 @@ pub mod close_agent { call_id, sender_thread_id: session.conversation_id, receiver_thread_id: agent_id, + receiver_agent_nickname, + receiver_agent_role, status: status.clone(), } .into(), @@ -687,6 +756,43 @@ fn agent_id(id: &str) -> Result { .map_err(|e| FunctionCallError::RespondToModel(format!("invalid agent id {id}: {e:?}"))) } +fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + fn collab_spawn_error(err: CodexErr) -> FunctionCallError { match err { CodexErr::UnsupportedOperation(_) => { diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index f77a4ec16..6583c19f9 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -632,6 +632,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { new_thread_id, prompt, status, + .. }) => { let success = new_thread_id.is_some() && !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; @@ -669,6 +670,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { receiver_thread_id, prompt, status, + .. }) => { let success = !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; @@ -687,6 +689,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, receiver_thread_ids, call_id, + .. }) => { ts_msg!( self, @@ -703,6 +706,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, call_id, statuses, + .. }) => { if statuses.is_empty() { ts_msg!( @@ -755,6 +759,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { sender_thread_id: _, receiver_thread_id, status, + .. }) => { let success = !is_collab_status_failure(&status); let title_style = if success { self.green } else { self.red }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 35cb962ee..c04fcdb53 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -572,6 +572,8 @@ fn collab_spawn_begin_and_end_emit_item_events() { call_id: "call-10".to_string(), sender_thread_id, new_thread_id: Some(new_thread_id), + new_agent_nickname: None, + new_agent_role: None, prompt: prompt.clone(), status: AgentStatus::Running, }), @@ -623,6 +625,7 @@ fn collab_wait_end_without_begin_synthesizes_failed_item() { EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { sender_thread_id, call_id: "call-11".to_string(), + agent_statuses: Vec::new(), statuses: statuses.clone(), }), ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a759f1512..5b3d41888 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2747,6 +2747,32 @@ pub struct CollabAgentSpawnBeginEvent { pub prompt: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct CollabAgentRef { + /// Thread ID of the receiver/new agent. + pub thread_id: ThreadId, + /// Optional nickname assigned to an AgentControl-spawned sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct CollabAgentStatusEntry { + /// Thread ID of the receiver/new agent. + pub thread_id: ThreadId, + /// Optional nickname assigned to an AgentControl-spawned sub-agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + #[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")] + pub agent_role: Option, + /// Last known status of the agent. + pub status: AgentStatus, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] pub struct CollabAgentSpawnEndEvent { /// Identifier for the collab tool call. @@ -2755,6 +2781,12 @@ pub struct CollabAgentSpawnEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the newly spawned agent, if it was created. pub new_thread_id: Option, + /// Optional nickname assigned to the new agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_agent_nickname: Option, + /// Optional role assigned to the new agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_agent_role: Option, /// Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the /// beginning. pub prompt: String, @@ -2783,6 +2815,12 @@ pub struct CollabAgentInteractionEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Prompt sent from the sender to the receiver. Can be empty to prevent CoT /// leaking at the beginning. pub prompt: String, @@ -2796,6 +2834,9 @@ pub struct CollabWaitingBeginEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receivers. pub receiver_thread_ids: Vec, + /// Optional nicknames/roles for receivers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub receiver_agents: Vec, /// ID of the waiting call. pub call_id: String, } @@ -2806,6 +2847,9 @@ pub struct CollabWaitingEndEvent { pub sender_thread_id: ThreadId, /// ID of the waiting call. pub call_id: String, + /// Optional receiver metadata paired with final statuses. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_statuses: Vec, /// Last known status of the receiver agents reported to the sender agent. pub statuses: HashMap, } @@ -2828,6 +2872,12 @@ pub struct CollabCloseEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Last known status of the receiver agent reported to the sender agent before /// the close. pub status: AgentStatus, @@ -2841,6 +2891,12 @@ pub struct CollabResumeBeginEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] @@ -2851,6 +2907,12 @@ pub struct CollabResumeEndEvent { pub sender_thread_id: ThreadId, /// Thread ID of the receiver. pub receiver_thread_id: ThreadId, + /// Optional nickname assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_nickname: Option, + /// Optional role assigned to the receiver agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receiver_agent_role: Option, /// Last known status of the receiver agent reported to the sender agent after /// resume. pub status: AgentStatus, diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index e26bb8785..57df8b889 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -3,7 +3,9 @@ use crate::render::line_utils::prefix_lines; use crate::text_formatting::truncate_text; use codex_core::protocol::AgentStatus; use codex_core::protocol::CollabAgentInteractionEndEvent; +use codex_core::protocol::CollabAgentRef; use codex_core::protocol::CollabAgentSpawnEndEvent; +use codex_core::protocol::CollabAgentStatusEntry; use codex_core::protocol::CollabCloseEndEvent; use codex_core::protocol::CollabResumeBeginEvent; use codex_core::protocol::CollabResumeEndEvent; @@ -14,282 +16,517 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use std::collections::HashMap; +use std::collections::HashSet; const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; +#[derive(Clone, Copy)] +struct AgentLabel<'a> { + thread_id: Option, + nickname: Option<&'a str>, + role: Option<&'a str>, +} + pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { - call_id, + call_id: _, sender_thread_id: _, new_thread_id, + new_agent_nickname, + new_agent_role, prompt, - status, + status: _, } = ev; - let new_agent = new_thread_id - .map(|id| Span::from(id.to_string())) - .unwrap_or_else(|| Span::from("not created").dim()); - let mut details = vec![ - detail_line("call", call_id), - detail_line("agent", new_agent), - status_line(&status), - ]; + + let title = match new_thread_id { + Some(thread_id) => title_with_agent( + "Spawned", + AgentLabel { + thread_id: Some(thread_id), + nickname: new_agent_nickname.as_deref(), + role: new_agent_role.as_deref(), + }, + ), + None => title_text("Agent spawn failed"), + }; + + let mut details = Vec::new(); if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Agent spawned", details) + collab_event(title, details) } pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { let CollabAgentInteractionEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, prompt, - status, + status: _, } = ev; - let mut details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; + + let title = title_with_agent( + "Sent input to", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ); + + let mut details = Vec::new(); if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Input sent", details) + collab_event(title, details) } pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { - call_id, sender_thread_id: _, receiver_thread_ids, + receiver_agents, + call_id: _, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receivers", format_thread_ids(&receiver_thread_ids)), - ]; - collab_event("Waiting for agents", details) + let receiver_agents = merge_wait_receivers(&receiver_thread_ids, receiver_agents); + + let title = match receiver_agents.as_slice() { + [receiver] => title_with_agent("Waiting for", agent_label_from_ref(receiver)), + [] => title_text("Waiting for agents"), + _ => title_text(format!("Waiting for {} agents", receiver_agents.len())), + }; + + let details = if receiver_agents.len() > 1 { + receiver_agents + .iter() + .map(|receiver| agent_label_line(agent_label_from_ref(receiver))) + .collect() + } else { + Vec::new() + }; + + collab_event(title, details) } pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { - call_id, + call_id: _, sender_thread_id: _, + agent_statuses, statuses, } = ev; - let mut details = vec![detail_line("call", call_id)]; - details.extend(wait_complete_lines(&statuses)); - collab_event("Wait complete", details) + let details = wait_complete_lines(&statuses, &agent_statuses); + collab_event(title_text("Finished waiting"), details) } pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { let CollabCloseEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, - status, + receiver_agent_nickname, + receiver_agent_role, + status: _, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; - collab_event("Agent closed", details) + + collab_event( + title_with_agent( + "Closed", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + Vec::new(), + ) } pub(crate) fn resume_begin(ev: CollabResumeBeginEvent) -> PlainHistoryCell { let CollabResumeBeginEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - ]; - collab_event("Resuming agent", details) + + collab_event( + title_with_agent( + "Resuming", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + Vec::new(), + ) } pub(crate) fn resume_end(ev: CollabResumeEndEvent) -> PlainHistoryCell { let CollabResumeEndEvent { - call_id, + call_id: _, sender_thread_id: _, receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, status, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("receiver", receiver_thread_id.to_string()), - status_line(&status), - ]; - collab_event("Agent resumed", details) + + collab_event( + title_with_agent( + "Resumed", + AgentLabel { + thread_id: Some(receiver_thread_id), + nickname: receiver_agent_nickname.as_deref(), + role: receiver_agent_role.as_deref(), + }, + ), + vec![status_summary_line(&status)], + ) } -fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { - let title = title.into(); - let mut lines: Vec> = - vec![vec![Span::from("• ").dim(), Span::from(title).bold()].into()]; +fn collab_event(title: Line<'static>, details: Vec>) -> PlainHistoryCell { + let mut lines: Vec> = vec![title]; if !details.is_empty() { lines.extend(prefix_lines(details, " └ ".dim(), " ".into())); } PlainHistoryCell::new(lines) } -fn detail_line(label: &str, value: impl Into>) -> Line<'static> { - vec![Span::from(format!("{label}: ")).dim(), value.into()].into() +fn title_text(title: impl Into) -> Line<'static> { + title_spans_line(vec![Span::from(title.into()).bold()]) } -fn status_line(status: &AgentStatus) -> Line<'static> { - detail_line("status", status_span(status)) +fn title_with_agent(prefix: &str, agent: AgentLabel<'_>) -> Line<'static> { + let mut spans = vec![Span::from(format!("{prefix} ")).bold()]; + spans.extend(agent_label_spans(agent)); + title_spans_line(spans) } -fn status_span(status: &AgentStatus) -> Span<'static> { - match status { - AgentStatus::PendingInit => Span::from("pending init").dim(), - AgentStatus::Running => Span::from("running").cyan().bold(), - AgentStatus::Completed(_) => Span::from("completed").green(), - AgentStatus::Errored(_) => Span::from("errored").red(), - AgentStatus::Shutdown => Span::from("shutdown").dim(), - AgentStatus::NotFound => Span::from("not found").red(), +fn title_spans_line(mut spans: Vec>) -> Line<'static> { + let mut title = Vec::with_capacity(spans.len() + 1); + title.push(Span::from("• ").dim()); + title.append(&mut spans); + title.into() +} + +fn agent_label_from_ref(agent: &CollabAgentRef) -> AgentLabel<'_> { + AgentLabel { + thread_id: Some(agent.thread_id), + nickname: agent.agent_nickname.as_deref(), + role: agent.agent_role.as_deref(), } } +fn agent_label_line(agent: AgentLabel<'_>) -> Line<'static> { + agent_label_spans(agent).into() +} + +fn agent_label_spans(agent: AgentLabel<'_>) -> Vec> { + let mut spans = Vec::new(); + let nickname = agent + .nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let role = agent.role.map(str::trim).filter(|role| !role.is_empty()); + + if let Some(nickname) = nickname { + spans.push(Span::from(nickname.to_string()).light_blue().bold()); + } else if let Some(thread_id) = agent.thread_id { + spans.push(Span::from(thread_id.to_string()).dim()); + } else { + spans.push(Span::from("agent").dim()); + } + + if let Some(role) = role { + spans.push(Span::from(" ").dim()); + spans.push(Span::from(format!("[{role}]")).dim()); + } + + spans +} + fn prompt_line(prompt: &str) -> Option> { let trimmed = prompt.trim(); if trimmed.is_empty() { None } else { - Some(detail_line( - "prompt", - Span::from(truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES)).dim(), - )) + Some(Line::from(Span::from(truncate_text( + trimmed, + COLLAB_PROMPT_PREVIEW_GRAPHEMES, + )))) } } -fn format_thread_ids(ids: &[ThreadId]) -> Span<'static> { - if ids.is_empty() { - return Span::from("none").dim(); +fn merge_wait_receivers( + receiver_thread_ids: &[ThreadId], + mut receiver_agents: Vec, +) -> Vec { + if receiver_agents.is_empty() { + return receiver_thread_ids + .iter() + .map(|thread_id| CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }) + .collect(); } - let joined = ids + + let mut seen = receiver_agents .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - Span::from(joined) -} - -fn wait_complete_lines(statuses: &HashMap) -> Vec> { - if statuses.is_empty() { - return vec![detail_line("agents", Span::from("none").dim())]; - } - - let mut pending_init = 0usize; - let mut running = 0usize; - let mut completed = 0usize; - let mut errored = 0usize; - let mut shutdown = 0usize; - let mut not_found = 0usize; - for status in statuses.values() { - match status { - AgentStatus::PendingInit => pending_init += 1, - AgentStatus::Running => running += 1, - AgentStatus::Completed(_) => completed += 1, - AgentStatus::Errored(_) => errored += 1, - AgentStatus::Shutdown => shutdown += 1, - AgentStatus::NotFound => not_found += 1, + .map(|agent| agent.thread_id) + .collect::>(); + for thread_id in receiver_thread_ids { + if seen.insert(*thread_id) { + receiver_agents.push(CollabAgentRef { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + }); } } + receiver_agents +} - let mut summary = vec![Span::from(format!("{} total", statuses.len())).dim()]; - push_status_count( - &mut summary, - pending_init, - "pending init", - ratatui::prelude::Stylize::dim, - ); - push_status_count(&mut summary, running, "running", |span| span.cyan().bold()); - push_status_count( - &mut summary, - completed, - "completed", - ratatui::prelude::Stylize::green, - ); - push_status_count( - &mut summary, - errored, - "errored", - ratatui::prelude::Stylize::red, - ); - push_status_count( - &mut summary, - shutdown, - "shutdown", - ratatui::prelude::Stylize::dim, - ); - push_status_count( - &mut summary, - not_found, - "not found", - ratatui::prelude::Stylize::red, - ); +fn wait_complete_lines( + statuses: &HashMap, + agent_statuses: &[CollabAgentStatusEntry], +) -> Vec> { + if statuses.is_empty() && agent_statuses.is_empty() { + return vec![Line::from(Span::from("No agents completed yet").dim())]; + } - let mut entries: Vec<(String, &AgentStatus)> = statuses - .iter() - .map(|(thread_id, status)| (thread_id.to_string(), status)) - .collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let entries = if agent_statuses.is_empty() { + let mut entries = statuses + .iter() + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries + } else { + let mut entries = agent_statuses.to_vec(); + let seen = entries + .iter() + .map(|entry| entry.thread_id) + .collect::>(); + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries + }; - let mut lines = Vec::with_capacity(entries.len() + 1); - lines.push(detail_line_spans("agents", summary)); - lines.extend(entries.into_iter().map(|(thread_id, status)| { - let mut spans = vec![ - Span::from(thread_id).dim(), - Span::from(" ").dim(), - status_span(status), - ]; - match status { - AgentStatus::Completed(Some(message)) => { + entries + .into_iter() + .map(|entry| { + let CollabAgentStatusEntry { + thread_id, + agent_nickname, + agent_role, + status, + } = entry; + let mut spans = agent_label_spans(AgentLabel { + thread_id: Some(thread_id), + nickname: agent_nickname.as_deref(), + role: agent_role.as_deref(), + }); + spans.push(Span::from(": ").dim()); + spans.extend(status_summary_spans(&status)); + spans.into() + }) + .collect() +} + +fn status_summary_line(status: &AgentStatus) -> Line<'static> { + status_summary_spans(status).into() +} + +fn status_summary_spans(status: &AgentStatus) -> Vec> { + match status { + AgentStatus::PendingInit => vec![Span::from("Pending init").dim()], + AgentStatus::Running => vec![Span::from("Running").cyan().bold()], + AgentStatus::Completed(message) => { + let mut spans = vec![Span::from("Completed").green()]; + if let Some(message) = message.as_ref() { let message_preview = truncate_text( &message.split_whitespace().collect::>().join(" "), COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES, ); - spans.push(Span::from(": ").dim()); - spans.push(Span::from(message_preview)); + if !message_preview.is_empty() { + spans.push(Span::from(" - ").dim()); + spans.push(Span::from(message_preview)); + } } - AgentStatus::Errored(error) => { - let error_preview = truncate_text( - &error.split_whitespace().collect::>().join(" "), - COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, - ); - spans.push(Span::from(": ").dim()); + spans + } + AgentStatus::Errored(error) => { + let mut spans = vec![Span::from("Error").red()]; + let error_preview = truncate_text( + &error.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, + ); + if !error_preview.is_empty() { + spans.push(Span::from(" - ").dim()); spans.push(Span::from(error_preview).dim()); } - _ => {} + spans } - spans.into() - })); - lines + AgentStatus::Shutdown => vec![Span::from("Shutdown").dim()], + AgentStatus::NotFound => vec![Span::from("Not found").red()], + } } -fn push_status_count( - spans: &mut Vec>, - count: usize, - label: &'static str, - style: impl FnOnce(Span<'static>) -> Span<'static>, -) { - if count == 0 { - return; +#[cfg(test)] +mod tests { + use super::*; + use crate::history_cell::HistoryCell; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::style::Color; + use ratatui::style::Modifier; + + #[test] + fn collab_events_snapshot() { + let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") + .expect("valid robie thread id"); + let bob_id = ThreadId::from_string("00000000-0000-0000-0000-000000000003") + .expect("valid bob thread id"); + + let spawn = spawn_end(CollabAgentSpawnEndEvent { + call_id: "call-spawn".to_string(), + sender_thread_id, + new_thread_id: Some(robie_id), + new_agent_nickname: Some("Robie".to_string()), + new_agent_role: Some("explorer".to_string()), + prompt: "Compute 11! and reply with just the integer result.".to_string(), + status: AgentStatus::PendingInit, + }); + + let send = interaction_end(CollabAgentInteractionEndEvent { + call_id: "call-send".to_string(), + sender_thread_id, + receiver_thread_id: robie_id, + receiver_agent_nickname: Some("Robie".to_string()), + receiver_agent_role: Some("explorer".to_string()), + prompt: "Please continue and return the answer only.".to_string(), + status: AgentStatus::Running, + }); + + let waiting = waiting_begin(CollabWaitingBeginEvent { + sender_thread_id, + receiver_thread_ids: vec![robie_id], + receiver_agents: vec![CollabAgentRef { + thread_id: robie_id, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + }], + call_id: "call-wait".to_string(), + }); + + let mut statuses = HashMap::new(); + statuses.insert( + robie_id, + AgentStatus::Completed(Some("39916800".to_string())), + ); + statuses.insert(bob_id, AgentStatus::Errored("tool timeout".to_string())); + let finished = waiting_end(CollabWaitingEndEvent { + sender_thread_id, + call_id: "call-wait".to_string(), + agent_statuses: vec![ + CollabAgentStatusEntry { + thread_id: robie_id, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + status: AgentStatus::Completed(Some("39916800".to_string())), + }, + CollabAgentStatusEntry { + thread_id: bob_id, + agent_nickname: Some("Bob".to_string()), + agent_role: Some("worker".to_string()), + status: AgentStatus::Errored("tool timeout".to_string()), + }, + ], + statuses, + }); + + let close = close_end(CollabCloseEndEvent { + call_id: "call-close".to_string(), + sender_thread_id, + receiver_thread_id: robie_id, + receiver_agent_nickname: Some("Robie".to_string()), + receiver_agent_role: Some("explorer".to_string()), + status: AgentStatus::Completed(Some("39916800".to_string())), + }); + + let snapshot = [spawn, send, waiting, finished, close] + .iter() + .map(cell_to_text) + .collect::>() + .join("\n\n"); + assert_snapshot!("collab_agent_transcript", snapshot); } - spans.push(Span::from(" · ").dim()); - spans.push(style(Span::from(format!("{count} {label}")))); -} + #[test] + fn title_styles_nickname_and_role() { + let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001") + .expect("valid sender thread id"); + let robie_id = ThreadId::from_string("00000000-0000-0000-0000-000000000002") + .expect("valid robie thread id"); + let cell = spawn_end(CollabAgentSpawnEndEvent { + call_id: "call-spawn".to_string(), + sender_thread_id, + new_thread_id: Some(robie_id), + new_agent_nickname: Some("Robie".to_string()), + new_agent_role: Some("explorer".to_string()), + prompt: String::new(), + status: AgentStatus::PendingInit, + }); -fn detail_line_spans(label: &str, mut value: Vec>) -> Line<'static> { - let mut spans = Vec::with_capacity(value.len() + 1); - spans.push(Span::from(format!("{label}: ")).dim()); - spans.append(&mut value); - spans.into() + let lines = cell.display_lines(200); + let title = &lines[0]; + assert_eq!(title.spans[2].content.as_ref(), "Robie"); + assert_eq!(title.spans[2].style.fg, Some(Color::LightBlue)); + assert!(title.spans[2].style.add_modifier.contains(Modifier::BOLD)); + assert_eq!(title.spans[4].content.as_ref(), "[explorer]"); + assert!(title.spans[4].style.add_modifier.contains(Modifier::DIM)); + } + + fn cell_to_text(cell: &PlainHistoryCell) -> String { + cell.display_lines(200) + .iter() + .map(line_to_text) + .collect::>() + .join("\n") + } + + fn line_to_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>() + .join("") + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap new file mode 100644 index 000000000..19001a70d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__multi_agents__tests__collab_agent_transcript.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/multi_agents.rs +expression: snapshot +--- +• Spawned Robie [explorer] + └ Compute 11! and reply with just the integer result. + +• Sent input to Robie [explorer] + └ Please continue and return the answer only. + +• Waiting for Robie [explorer] + +• Finished waiting + └ Robie [explorer]: Completed - 39916800 + Bob [worker]: Error - tool timeout + +• Closed Robie [explorer]