diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 0fb54c9257..c86f7f5939 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -3212,6 +3212,7 @@ fn collab_tool_call_outcome( match status { CollabAgentToolCallStatus::InProgress => None, CollabAgentToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Interrupted => Some((ToolItemTerminalStatus::Interrupted, None)), CollabAgentToolCallStatus::Failed => Some(( ToolItemTerminalStatus::Failed, Some(ToolItemFailureKind::ToolError), @@ -3236,6 +3237,10 @@ fn collab_agent_tool_name(tool: &CollabAgentTool) -> &'static str { CollabAgentTool::ResumeAgent => "resume_agent", CollabAgentTool::Wait => "wait_agent", CollabAgentTool::CloseAgent => "close_agent", + CollabAgentTool::SendMessage => "send_message", + CollabAgentTool::FollowupTask => "followup_task", + CollabAgentTool::InterruptAgent => "interrupt_agent", + CollabAgentTool::ListAgents => "list_agents", } } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 7f152ee142..0a4235e981 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -783,7 +783,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -791,7 +795,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 833e666633..5cb982f4c5 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 @@ -8115,7 +8115,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -8123,7 +8127,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 c77737fb16..08dd4e5ba9 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 @@ -4076,7 +4076,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -4084,7 +4088,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 bf28b03d62..14f21e5de0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -65,7 +65,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -73,7 +77,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 4791191a32..f43c997b8e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -65,7 +65,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -73,7 +77,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 0451f740f3..a60b368f27 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -204,7 +204,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -212,7 +216,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 0332dfde65..3afaec5f92 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -285,7 +285,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -293,7 +297,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 105d934b73..5fd8ffa781 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -207,7 +207,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -215,7 +219,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 19c11d8a4c..884d9fd49e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -207,7 +207,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -215,7 +219,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 8da91c2226..18e82fa2b0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -207,7 +207,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -215,7 +219,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 390882d3d9..90c8e68f70 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -285,7 +285,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -293,7 +297,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 4687c7e048..3f6a9c2ea9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -207,7 +207,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -215,7 +219,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 ceb64b2d92..0006341c8c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -285,7 +285,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -293,7 +297,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 9de1753c59..f6cc5dc4ec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -207,7 +207,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -215,7 +219,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 dc72d5ebc9..876aaca15d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -207,7 +207,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -215,7 +219,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 43060b2d44..86b07a6c15 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -204,7 +204,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -212,7 +216,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 5728fce1e1..824b5916eb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -204,7 +204,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -212,7 +216,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "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 b57f48f30b..a02a4d10b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -204,7 +204,11 @@ "sendInput", "resumeAgent", "wait", - "closeAgent" + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" ], "type": "string" }, @@ -212,7 +216,8 @@ "enum": [ "inProgress", "completed", - "failed" + "failed", + "interrupted" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index f5083afb25..d28f194bea 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 4a3d3f45ca..cd8a109d13 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentTool.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentTool.ts index 3637853a38..9d8673ed09 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentTool.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentTool.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CollabAgentTool = "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent"; +export type CollabAgentTool = "spawnAgent" | "sendInput" | "resumeAgent" | "wait" | "closeAgent" | "sendMessage" | "followupTask" | "interruptAgent" | "listAgents"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentToolCallStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentToolCallStatus.ts index f21f7bd5d5..2309bb8e7d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentToolCallStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CollabAgentToolCallStatus.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed"; +export type CollabAgentToolCallStatus = "inProgress" | "completed" | "failed" | "interrupted"; 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 5c1783c053..33248bb4a5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -1091,6 +1091,10 @@ pub enum CollabAgentTool { ResumeAgent, Wait, CloseAgent, + SendMessage, + FollowupTask, + InterruptAgent, + ListAgents, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1183,6 +1187,7 @@ pub enum CollabAgentToolCallStatus { InProgress, Completed, Failed, + Interrupted, } impl From for CollabAgentTool { @@ -1193,6 +1198,10 @@ impl From for CollabAgentTool { CoreCollabAgentTool::ResumeAgent => Self::ResumeAgent, CoreCollabAgentTool::Wait => Self::Wait, CoreCollabAgentTool::CloseAgent => Self::CloseAgent, + CoreCollabAgentTool::SendMessage => Self::SendMessage, + CoreCollabAgentTool::FollowupTask => Self::FollowupTask, + CoreCollabAgentTool::InterruptAgent => Self::InterruptAgent, + CoreCollabAgentTool::ListAgents => Self::ListAgents, } } } @@ -1203,6 +1212,7 @@ impl From for CollabAgentToolCallStatus { CoreCollabAgentToolCallStatus::InProgress => Self::InProgress, CoreCollabAgentToolCallStatus::Completed => Self::Completed, CoreCollabAgentToolCallStatus::Failed => Self::Failed, + CoreCollabAgentToolCallStatus::Interrupted => Self::Interrupted, } } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cbaf71df2a..0bfad24875 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1650,6 +1650,12 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `mcpToolCall` — `{id, server, tool, status, arguments, appContext, mcpAppResourceUri?, pluginId, readOnlyHint, result?, error?}` describing MCP calls; `appContext` is `{connectorId, linkId, resourceUri, appName, actionName}` for calls through a trusted MCP app, where `connectorId` identifies the connector that owns the tool, `linkId` identifies the app link, `resourceUri` points to the widget template, `appName` is the connector's display name, and `actionName` is the stable connector `Action.name`. `readOnlyHint` is `true` for read-only tools, `false` for write-capable tools, and `null` when the annotation is unavailable, including older rollout entries. The hint describes tool capability, not whether an invocation succeeded or performed a write; use `status`, `result`, and `error` to determine the execution outcome. `appName` and `actionName` may be null for older rollout entries. The top-level `mcpAppResourceUri` is deprecated and temporarily duplicated for client migration. `tool` identifies the raw MCP tool. `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. - `subAgentActivity` — `{id, kind, agentThreadId, agentPath}` describing Multi-Agent V2 lifecycle activity; `kind` is `started`, `interacted`, `interrupted`, or `completed`. A successful child completion is attributed to the parent turn that spawned it, so its `item/completed` notification may arrive after that turn's `turn/completed` notification and is included with that turn when history is read. + + The `CollabAgentTool` schema also includes `sendMessage`, `followupTask`, `interruptAgent`, and + `listAgents` for private Multi-Agent V2 analytics. These calls do not emit public collaborator tool + items; their existing `subAgentActivity` notifications are unchanged, and `list_agents` emits no + activity item. Calls cancelled during handler execution are recorded privately with status + `interrupted`, distinct from tool failures. - `webSearch` — `{id, query, action?, results?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. For standalone web search, `results` contains the out-of-band structured result DTOs returned by `/v1/alpha/search`; clients should ignore result types and fields they do not understand. - `imageGeneration` — `{id, status, revisedPrompt, result, transparentBackground, savedPath?}` for a generated image. `transparentBackground` is `true` when the Images API reports a transparent background, `false` when it reports an opaque background, and `null` when the background is automatic, unavailable, or the item has not completed. The field is always present on v2 item payloads, including persisted and resumed items. - `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index d29113c188..edffe2c2aa 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -1,15 +1,20 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -20,6 +25,7 @@ use codex_config::types::OtelHttpProtocol; use codex_core::config::ConfigBuilder; use codex_core_plugins::loader::curated_plugin_cache_version; use codex_core_plugins::store::PluginStore; +use codex_features::Feature; use codex_plugin::PluginId; use core_test_support::responses; use core_test_support::skip_if_no_network; @@ -39,9 +45,198 @@ use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +use wiremock::matchers::path_regex; const SERVICE_VERSION: &str = "0.0.0-test"; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_v2_tools_emit_collaborator_analytics() -> Result<()> { + const PARENT_PROMPT: &str = "exercise collaborator analytics"; + const READ_TIMEOUT: Duration = Duration::from_secs(30); + let calls = [ + ( + "spawn_agent", + json!({"task_name": "worker", "message": "PRIVATE_CHILD", "fork_turns": "none"}), + ), + ( + "send_message", + json!({"target": "worker", "message": "PRIVATE_MESSAGE"}), + ), + ( + "followup_task", + json!({"target": "worker", "message": "PRIVATE_FOLLOWUP"}), + ), + ("interrupt_agent", json!({"target": "worker"})), + ("list_agents", json!({})), + ( + "send_message", + json!({"target": "missing", "message": "PRIVATE_MESSAGE"}), + ), + ( + "followup_task", + json!({"target": "/root", "message": "PRIVATE_FOLLOWUP"}), + ), + ("interrupt_agent", json!({"target": "/root"})), + ("list_agents", json!({"unexpected": true})), + ("send_message", json!({})), + ]; + let server = responses::start_mock_server().await; + for (index, (tool, args)) in calls.iter().enumerate() { + let response_id = format!("parent-response-{index}"); + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + String::from_utf8_lossy(&request.body).contains(PARENT_PROMPT) + }, + responses::sse(vec![ + responses::ev_response_created(&response_id), + responses::ev_function_call_with_namespace( + &format!("call-{index}"), + "collaboration", + tool, + &args.to_string(), + ), + responses::ev_completed(&response_id), + ]), + ) + .await; + } + // Child turns and the final parent request only need an ordinary completion. + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(responses::sse(vec![responses::ev_completed( + "response-done", + )])), + ) + .with_priority(10) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()) + .enable_feature(Feature::MultiAgentV2) + .with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri())) + .write(codex_home.path())?; + mount_analytics_capture(&server, codex_home.path()).await?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_managed_config() + .build_initialized() + .await?; + let thread = app_server + .start_thread(ThreadStartParams::default()) + .await? + .thread; + let completed = app_server + .start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: PARENT_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let read: ThreadReadResponse = app_server + .request(|request_id| ClientRequest::ThreadRead { + request_id, + params: ThreadReadParams { + thread_id: thread.id.clone(), + include_turns: true, + }, + }) + .await?; + let items = &read + .thread + .turns + .iter() + .find(|turn| turn.id == completed.turn.id) + .expect("completed parent turn should be readable") + .items; + let child_thread_id = items + .iter() + .find_map(|item| match item { + ThreadItem::SubAgentActivity { + id, + agent_thread_id, + .. + } if id == "call-0" => Some(agent_thread_id.clone()), + _ => None, + }) + .expect("spawn should retain its public activity item"); + assert!( + !items + .iter() + .any(|item| matches!(item, ThreadItem::CollabAgentToolCall { .. })) + ); + let activity_ids = items + .iter() + .filter_map(|item| match item { + ThreadItem::SubAgentActivity { id, .. } if id.starts_with("call-") => Some(id.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(activity_ids, vec!["call-0", "call-1", "call-2", "call-3"]); + + for (index, (tool, _)) in calls.iter().enumerate() { + let event = wait_for_matching_analytics_event(&server, READ_TIMEOUT, |event| { + event["event_type"] == "codex_collab_agent_tool_call_event" + && event["event_params"]["item_id"] == format!("call-{index}") + }) + .await?; + let params = &event["event_params"]; + let receivers = match index { + 0..=3 => vec![child_thread_id.clone()], + 6 | 7 => vec![thread.id.clone()], + _ => Vec::new(), + }; + assert_eq!( + json!({ + "tool": params["tool_name"], + "sender": params["sender_thread_id"], + "receivers": params["receiver_thread_ids"], + "receiverCount": params["receiver_thread_count"], + "turn": params["turn_id"], + "status": params["terminal_status"], + "origin": params["originating_response_id"], + "failure": params["failure_kind"], + "hasDuration": params["execution_duration_ms"].is_u64(), + }), + json!({ + "tool": tool, + "sender": thread.id, + "receivers": receivers, + "receiverCount": receivers.len(), + "turn": completed.turn.id, + "status": if index < 5 { "completed" } else { "failed" }, + "origin": format!("parent-response-{index}"), + "failure": if index < 5 { None } else { Some("tool_error") }, + "hasDuration": true, + }) + ); + assert_eq!(params["duration_ms"], params["execution_duration_ms"]); + assert!(!event.to_string().contains("PRIVATE_")); + } + let turn_event = wait_for_matching_analytics_event(&server, READ_TIMEOUT, |event| { + event["event_type"] == "codex_turn_event" + && event["event_params"]["turn_id"] == completed.turn.id + }) + .await?; + assert_eq!( + json!({ + "total": turn_event["event_params"]["total_tool_call_count"], + "subagent": turn_event["event_params"]["subagent_tool_call_count"], + "dynamic": turn_event["event_params"]["dynamic_tool_call_count"], + }), + json!({"total": calls.len(), "subagent": calls.len(), "dynamic": 0}) + ); + + Ok(()) +} + fn set_metrics_exporter(config: &mut codex_core::config::Config) { config.otel.metrics_exporter = OtelExporterKind::OtlpHttp { endpoint: "http://localhost:4318".to_string(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index ad9f38927d..b82bdd6a0b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -36,6 +36,7 @@ pub(crate) use send_message::Handler as SendMessageHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; +mod analytics; mod followup_task; mod interrupt_agent; mod list_agents; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/analytics.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/analytics.rs new file mode 100644 index 0000000000..a8a0ea6b10 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/analytics.rs @@ -0,0 +1,62 @@ +use codex_analytics::AnalyticsEventsClient; +use codex_protocol::ThreadId; +use codex_protocol::items::CollabAgentTool; +use codex_protocol::items::CollabAgentToolCallItem; +use codex_protocol::items::CollabAgentToolCallStatus; + +use crate::tools::context::ToolInvocation; +use crate::turn_timing::now_unix_timestamp_ms; + +/// Records private collaborator analytics without emitting another public tool item. +pub(super) struct ToolCallAnalytics { + client: AnalyticsEventsClient, + turn_id: String, + item: CollabAgentToolCallItem, + started_at_ms: i64, +} + +impl ToolCallAnalytics { + pub(super) fn new(invocation: &ToolInvocation, tool: CollabAgentTool) -> Self { + Self { + client: invocation.session.services.analytics_events_client.clone(), + turn_id: invocation.turn.sub_id.clone(), + item: CollabAgentToolCallItem { + // Activity and analytics must use the same ID for turn-count deduplication. + id: invocation.call_id.clone(), + tool, + status: CollabAgentToolCallStatus::Interrupted, + sender_thread_id: invocation.session.thread_id, + receiver_thread_ids: Vec::new(), + receiver_agents: Vec::new(), + prompt: None, + model: None, + reasoning_effort: None, + agents_states: Default::default(), + }, + started_at_ms: now_unix_timestamp_ms(), + } + } + + pub(super) fn set_receiver(&mut self, thread_id: ThreadId) { + self.item.receiver_thread_ids = vec![thread_id]; + } + + pub(super) fn finish(mut self, result: &Result) { + self.item.status = if result.is_ok() { + CollabAgentToolCallStatus::Completed + } else { + CollabAgentToolCallStatus::Failed + }; + } +} + +impl Drop for ToolCallAnalytics { + fn drop(&mut self) { + self.client.track_collab_tool_call( + self.turn_id.clone(), + self.item.clone(), + self.started_at_ms, + now_unix_timestamp_ms(), + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs index bd4dafe7c2..244fe6e08b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs @@ -1,3 +1,4 @@ +use super::analytics::ToolCallAnalytics; use super::message_tool::FollowupTaskArgs; use super::message_tool::MessageDeliveryMode; use super::message_tool::handle_message_string_tool; @@ -17,7 +18,12 @@ impl ToolExecutor for Handler { } fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { - Box::pin(self.handle_call(invocation)) + Box::pin(async move { + let mut analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::FollowupTask); + let result = self.handle_call(invocation, &mut analytics).await; + analytics.finish(&result); + result + }) } } @@ -25,6 +31,7 @@ impl Handler { async fn handle_call( &self, invocation: ToolInvocation, + analytics: &mut ToolCallAnalytics, ) -> Result, FunctionCallError> { let arguments = function_arguments(invocation.payload.clone())?; let args: FollowupTaskArgs = parse_arguments(&arguments)?; @@ -33,6 +40,7 @@ impl Handler { MessageDeliveryMode::TriggerTurn, args.target, args.message, + analytics, ) .await .map(boxed_tool_output) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs index 7c22fc21eb..811c23d1e8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs @@ -1,3 +1,4 @@ +use super::analytics::ToolCallAnalytics; use super::*; use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2; use codex_protocol::error::CodexErrorDetails; @@ -16,15 +17,18 @@ impl ToolExecutor for Handler { fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { Box::pin(async move { - handle_interrupt_agent(invocation) - .await - .map(boxed_tool_output) + let mut analytics = + ToolCallAnalytics::new(&invocation, CollabAgentTool::InterruptAgent); + let result = handle_interrupt_agent(invocation, &mut analytics).await; + analytics.finish(&result); + result.map(boxed_tool_output) }) } } async fn handle_interrupt_agent( invocation: ToolInvocation, + analytics: &mut ToolCallAnalytics, ) -> Result { let ToolInvocation { session, @@ -36,6 +40,7 @@ async fn handle_interrupt_agent( let arguments = function_arguments(payload)?; let args: InterruptAgentArgs = parse_arguments(&arguments)?; let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; + analytics.set_receiver(agent_id); let receiver_agent = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index d1348d804d..48485c8063 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -1,3 +1,4 @@ +use super::analytics::ToolCallAnalytics; use super::*; use crate::agent::control::ListedAgent; use crate::tools::handlers::multi_agents_spec::create_list_agents_tool; @@ -15,7 +16,12 @@ impl ToolExecutor for Handler { } fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { - Box::pin(self.handle_call(invocation)) + Box::pin(async move { + let analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::ListAgents); + let result = self.handle_call(invocation).await; + analytics.finish(&result); + result + }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 553dced00d..8f514be495 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -3,6 +3,7 @@ //! `send_message` and `followup_task` share the same submission path and differ only in whether the //! resulting `InterAgentCommunication` should wake the target immediately. +use super::analytics::ToolCallAnalytics; use super::*; use crate::agent_communication::AgentCommunicationContext; use crate::agent_communication::AgentCommunicationKind; @@ -49,11 +50,12 @@ pub(super) fn message_content(message: String) -> Result Result { let message = message_content(message)?; let ToolInvocation { @@ -64,6 +66,7 @@ pub(crate) async fn handle_message_string_tool( .. } = invocation; let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; + analytics.set_receiver(receiver_thread_id); let receiver_agent = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs index 717e3bc920..3461827a03 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -1,3 +1,4 @@ +use super::analytics::ToolCallAnalytics; use super::message_tool::MessageDeliveryMode; use super::message_tool::SendMessageArgs; use super::message_tool::handle_message_string_tool; @@ -17,7 +18,12 @@ impl ToolExecutor for Handler { } fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { - Box::pin(self.handle_call(invocation)) + Box::pin(async move { + let mut analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::SendMessage); + let result = self.handle_call(invocation, &mut analytics).await; + analytics.finish(&result); + result + }) } } @@ -25,6 +31,7 @@ impl Handler { async fn handle_call( &self, invocation: ToolInvocation, + analytics: &mut ToolCallAnalytics, ) -> Result, FunctionCallError> { let arguments = function_arguments(invocation.payload.clone())?; let args: SendMessageArgs = parse_arguments(&arguments)?; @@ -33,6 +40,7 @@ impl Handler { MessageDeliveryMode::QueueOnly, args.target, args.message, + analytics, ) .await .map(boxed_tool_output) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index e8b1375114..488cbc52e9 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -244,6 +244,10 @@ impl EventProcessorWithJsonOutput { id: make_id(), details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { tool: match tool { + CollabAgentTool::SendMessage + | CollabAgentTool::FollowupTask + | CollabAgentTool::InterruptAgent + | CollabAgentTool::ListAgents => return None, CollabAgentTool::SpawnAgent => CollabTool::SpawnAgent, CollabAgentTool::SendInput => CollabTool::SendInput, CollabAgentTool::ResumeAgent => CollabTool::Wait, @@ -291,6 +295,7 @@ impl EventProcessorWithJsonOutput { CollabAgentToolCallStatus::InProgress => CollabToolCallStatus::InProgress, CollabAgentToolCallStatus::Completed => CollabToolCallStatus::Completed, CollabAgentToolCallStatus::Failed => CollabToolCallStatus::Failed, + CollabAgentToolCallStatus::Interrupted => return None, }, }), }), diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index feaf06387e..d7f4dddc5a 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -288,6 +288,10 @@ pub enum CollabAgentTool { ResumeAgent, Wait, CloseAgent, + SendMessage, + FollowupTask, + InterruptAgent, + ListAgents, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] @@ -296,6 +300,7 @@ pub enum CollabAgentToolCallStatus { InProgress, Completed, Failed, + Interrupted, } #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs index 4c7daf62ed..0d1a8d5cb3 100644 --- a/codex-rs/protocol/src/legacy_events.rs +++ b/codex-rs/protocol/src/legacy_events.rs @@ -248,6 +248,11 @@ impl CollabAgentToolCallItem { pub(crate) fn as_legacy_begin_event(&self, started_at_ms: i64) -> Option { let receiver_thread_id = self.receiver_thread_ids.first().copied(); match self.tool { + // V2 records these tool items privately for analytics, not legacy UI events. + CollabAgentTool::SendMessage + | CollabAgentTool::FollowupTask + | CollabAgentTool::InterruptAgent + | CollabAgentTool::ListAgents => None, CollabAgentTool::SpawnAgent => Some(EventMsg::CollabAgentSpawnBegin( CollabAgentSpawnBeginEvent { call_id: self.id.clone(), @@ -303,6 +308,10 @@ impl CollabAgentToolCallItem { } let receiver_thread_id = self.receiver_thread_ids.first().copied(); match self.tool { + CollabAgentTool::SendMessage + | CollabAgentTool::FollowupTask + | CollabAgentTool::InterruptAgent + | CollabAgentTool::ListAgents => None, CollabAgentTool::SpawnAgent => { let (new_agent_nickname, new_agent_role) = receiver_thread_id .map(|thread_id| self.receiver_agent_identity(thread_id)) diff --git a/codex-rs/tui/src/app/agent_status_feed.rs b/codex-rs/tui/src/app/agent_status_feed.rs index 3f78b24e70..081606e56d 100644 --- a/codex-rs/tui/src/app/agent_status_feed.rs +++ b/codex-rs/tui/src/app/agent_status_feed.rs @@ -158,6 +158,10 @@ fn activity_summary(item: &ThreadItem) -> Option { } ThreadItem::CollabAgentToolCall { tool, .. } => { let action = match tool { + CollabAgentTool::SendMessage + | CollabAgentTool::FollowupTask + | CollabAgentTool::InterruptAgent + | CollabAgentTool::ListAgents => return None, CollabAgentTool::SpawnAgent => "Spawned an agent", CollabAgentTool::SendInput => "Sent input to an agent", CollabAgentTool::ResumeAgent => "Resumed an agent", diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 9d571ba95a..cfa05f74dd 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -223,6 +223,11 @@ pub(crate) fn tool_call_history_cell( let prompt = prompt.as_deref().unwrap_or_default(); match tool { + // V2 uses SubAgentActivity for display; these variants are analytics-only. + CollabAgentTool::SendMessage + | CollabAgentTool::FollowupTask + | CollabAgentTool::InterruptAgent + | CollabAgentTool::ListAgents => None, CollabAgentTool::SpawnAgent => { if matches!(status, CollabAgentToolCallStatus::InProgress) { return None;