diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1ca4688272..2b9cfa1fed 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -188,7 +188,7 @@ Example with notification opt-out: - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. -- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. +- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); for v2 sub-agent threads, logs and rollout attachments are collected from the current loaded agent subtree sharing the same canonical agent-path prefix; returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home). - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 620a85a095..cd44d90cc8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -7215,20 +7215,35 @@ impl CodexMessageProcessor { } let snapshot = self.feedback.snapshot(conversation_id); let thread_id = snapshot.thread_id.clone(); + let feedback_thread_ids = match conversation_id { + Some(conversation_id) => self.resolve_feedback_thread_ids(conversation_id).await, + None => Vec::new(), + }; let sqlite_feedback_logs = if include_logs { if let Some(log_db) = self.log_db.as_ref() { log_db.flush().await; } let state_db_ctx = get_state_db(&self.config).await; - match (state_db_ctx.as_ref(), conversation_id) { - (Some(state_db_ctx), Some(conversation_id)) => { - let thread_id_text = conversation_id.to_string(); - match state_db_ctx.query_feedback_logs(&thread_id_text).await { + match state_db_ctx.as_ref() { + Some(state_db_ctx) if !feedback_thread_ids.is_empty() => { + let feedback_thread_id_text = feedback_thread_ids + .iter() + .map(ToString::to_string) + .collect::>(); + let feedback_thread_id_refs = feedback_thread_id_text + .iter() + .map(String::as_str) + .collect::>(); + match state_db_ctx + .query_feedback_logs_for_threads(&feedback_thread_id_refs) + .await + { Ok(logs) if logs.is_empty() => None, Ok(logs) => Some(logs), Err(err) => { warn!( - "failed to query feedback logs from sqlite for thread_id={thread_id_text}: {err}" + thread_ids = ?feedback_thread_id_text, + "failed to query feedback logs from sqlite for feedback upload: {err}" ); None } @@ -7240,15 +7255,12 @@ impl CodexMessageProcessor { None }; - let validated_rollout_path = if include_logs { - match conversation_id { - Some(conv_id) => self.resolve_rollout_path(conv_id).await, - None => None, - } + let rollout_paths = if include_logs { + self.resolve_feedback_rollout_paths(&feedback_thread_ids).await } else { - None + Vec::new() }; - let mut attachment_paths = validated_rollout_path.into_iter().collect::>(); + let mut attachment_paths = rollout_paths; if let Some(extra_log_files) = extra_log_files { attachment_paths.extend(extra_log_files); } @@ -7375,6 +7387,38 @@ impl CodexMessageProcessor { Err(_) => None, } } + + async fn resolve_feedback_thread_ids(&self, conversation_id: ThreadId) -> Vec { + let Ok(thread) = self.thread_manager.get_thread(conversation_id).await else { + return vec![conversation_id]; + }; + let session_source = thread.config_snapshot().await.session_source; + let Some(agent_path) = session_source.get_agent_path() else { + return vec![conversation_id]; + }; + if agent_path.is_root() { + return vec![conversation_id]; + } + + match self.thread_manager.list_subtree_thread_ids(conversation_id).await { + Ok(thread_ids) if !thread_ids.is_empty() => thread_ids, + Ok(_) | Err(_) => vec![conversation_id], + } + } + + async fn resolve_feedback_rollout_paths(&self, thread_ids: &[ThreadId]) -> Vec { + let mut seen = HashSet::new(); + let mut rollout_paths = Vec::new(); + for thread_id in thread_ids { + let Some(path) = self.resolve_rollout_path(*thread_id).await else { + continue; + }; + if seen.insert(path.clone()) { + rollout_paths.push(path); + } + } + rollout_paths + } } #[allow(clippy::too_many_arguments)] diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a3b9897688..692e0d559a 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -37,6 +37,7 @@ use codex_state::DirectionalThreadSpawnEdgeStatus; use serde::Serialize; use std::collections::HashMap; use std::collections::VecDeque; +use std::cmp::Ordering; use std::sync::Arc; use std::sync::Weak; use tokio::sync::watch; @@ -724,18 +725,7 @@ impl AgentControl { .transpose()?; let mut live_agents = self.state.live_agents(); - live_agents.sort_by(|left, right| { - left.agent_path - .as_deref() - .unwrap_or_default() - .cmp(right.agent_path.as_deref().unwrap_or_default()) - .then_with(|| { - left.agent_id - .map(|id| id.to_string()) - .unwrap_or_default() - .cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default()) - }) - }); + live_agents.sort_by(compare_agent_metadata); let mut agents = Vec::with_capacity(live_agents.len()); for metadata in live_agents { @@ -771,6 +761,23 @@ impl AgentControl { Ok(agents) } + pub(crate) fn list_agent_subtree_thread_ids_for_thread( + &self, + thread_id: ThreadId, + ) -> Vec { + let Some(agent_path) = self + .get_agent_metadata(thread_id) + .and_then(|metadata| metadata.agent_path) + else { + return Vec::new(); + }; + if agent_path.is_root() { + return Vec::new(); + } + + self.list_agent_thread_ids_for_prefix(&agent_path) + } + /// Starts a detached watcher for sub-agents spawned from another thread. /// /// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and @@ -1063,6 +1070,31 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b }) } +fn compare_agent_metadata(left: &AgentMetadata, right: &AgentMetadata) -> Ordering { + left.agent_path + .as_deref() + .unwrap_or_default() + .cmp(right.agent_path.as_deref().unwrap_or_default()) + .then_with(|| { + left.agent_id + .map(|id| id.to_string()) + .unwrap_or_default() + .cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default()) + }) +} + +impl AgentControl { + fn list_agent_thread_ids_for_prefix(&self, agent_path: &AgentPath) -> Vec { + let mut live_agents = self.state.live_agents(); + live_agents.sort_by(compare_agent_metadata); + live_agents + .into_iter() + .filter(|metadata| agent_matches_prefix(metadata.agent_path.as_ref(), agent_path)) + .filter_map(|metadata| metadata.agent_id) + .collect() + } +} + async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option { let pending_input = thread.codex.session.pending_input_snapshot().await; if let Some(message) = pending_input diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 7f272f9595..0263ceb79d 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -403,6 +403,13 @@ impl ThreadManager { self.state.get_thread(thread_id).await } + pub async fn list_subtree_thread_ids(&self, thread_id: ThreadId) -> CodexResult> { + self.state.get_thread(thread_id).await?; + Ok(self + .agent_control() + .list_agent_subtree_thread_ids_for_thread(thread_id)) + } + pub async fn start_thread(&self, config: Config) -> CodexResult { // Box delegated thread-spawn futures so these convenience wrappers do // not inline the full spawn path into every caller's async state. diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index c80f5f743b..c668b5e6c8 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -9,7 +9,10 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::user_input::UserInput; +use codex_protocol::AgentPath; use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use core_test_support::PathExt; @@ -304,6 +307,102 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { assert_eq!(models_mock.requests().len(), 1); } +#[tokio::test] +async fn list_subtree_thread_ids_returns_current_agent_and_descendants_only() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + let _ = config.features.enable(codex_features::Feature::MultiAgentV2); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager, + SessionSource::Exec, + CollaborationModesConfig::default(), + Arc::new(codex_exec_server::EnvironmentManager::new( + /*exec_server_url*/ None, + )), + ); + + let root = manager + .start_thread(config.clone()) + .await + .expect("root thread should start"); + let control = manager.agent_control(); + + let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()) + .expect("worker path"); + let helper_path = worker_path.join("helper").expect("helper path"); + let sibling_path = AgentPath::from_string("/root/researcher/other_worker".to_string()) + .expect("sibling path"); + + let worker = control + .spawn_agent_with_metadata( + config.clone(), + vec![UserInput::Text { + text: "worker".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(worker_path), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker should spawn"); + let helper = control + .spawn_agent_with_metadata( + config.clone(), + vec![UserInput::Text { + text: "helper".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker.thread_id, + depth: 2, + agent_path: Some(helper_path), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("helper should spawn"); + let _sibling = control + .spawn_agent_with_metadata( + config, + vec![UserInput::Text { + text: "sibling".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(sibling_path), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("sibling should spawn"); + + let thread_ids = manager + .list_subtree_thread_ids(worker.thread_id) + .await + .expect("subtree thread ids should load"); + + assert_eq!(thread_ids, vec![worker.thread_id, helper.thread_id]); +} + #[test] fn interrupted_fork_snapshot_appends_interrupt_boundary() { let committed_history = diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 98667f8f18..732d8f4cd2 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -559,6 +559,10 @@ pub(crate) fn feedback_upload_consent_params( } } } + header_lines.push(Line::from("").into()); + header_lines.push( + Line::from("V2 sub-agent reports may include subtree rollouts.".dim()).into(), + ); super::SelectionViewParams { footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap.new b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap.new new file mode 100644 index 0000000000..8c58256e66 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap.new @@ -0,0 +1,22 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 9134 +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + • codex-connectivity-diagnostics.txt + + Connectivity diagnostics + - OPENAI_BASE_URL is set and may affect connectivity. + - OPENAI_BASE_URL = hello + + V2 sub-agent reports may also include descendant rollouts in the same subtre + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs b/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs index 98667f8f18..732d8f4cd2 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/feedback_view.rs @@ -559,6 +559,10 @@ pub(crate) fn feedback_upload_consent_params( } } } + header_lines.push(Line::from("").into()); + header_lines.push( + Line::from("V2 sub-agent reports may include subtree rollouts.".dim()).into(), + ); super::SelectionViewParams { footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__feedback_upload_consent_popup.snap.new b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__feedback_upload_consent_popup.snap.new new file mode 100644 index 0000000000..a3e47d263e --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__feedback_upload_consent_popup.snap.new @@ -0,0 +1,22 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +assertion_line: 9850 +expression: popup +--- + Upload logs? + + The following files will be sent: + • codex-logs.log + • codex-connectivity-diagnostics.txt + + Connectivity diagnostics + - OPENAI_BASE_URL is set and may affect connectivity. + - OPENAI_BASE_URL = hello + + V2 sub-agent reports may also include descendant rollouts in the same subtre + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + + Press enter to confirm or esc to go back