feat: add multi-agent v2 support under /feedback

This commit is contained in:
jif-oai
2026-03-26 16:50:18 +01:00
parent 26c66f3ee1
commit 7f3597529c
9 changed files with 259 additions and 25 deletions
+1 -1
View File
@@ -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).
@@ -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::<Vec<_>>();
let feedback_thread_id_refs = feedback_thread_id_text
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
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::<Vec<_>>();
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<ThreadId> {
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<PathBuf> {
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)]
+44 -12
View File
@@ -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<ThreadId> {
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<ThreadId> {
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<String> {
let pending_input = thread.codex.session.pending_input_snapshot().await;
if let Some(message) = pending_input
+7
View File
@@ -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<Vec<ThreadId>> {
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<NewThread> {
// Box delegated thread-spawn futures so these convenience wrappers do
// not inline the full spawn path into every caller's async state.
+99
View File
@@ -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 =
@@ -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()),
@@ -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
@@ -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()),
@@ -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