Compare commits

...

2 Commits

Author SHA1 Message Date
jif-oai
05d5b23bd2 Merge branch 'main' into jif/feedback-agents 2026-03-26 18:08:52 +00:00
jif-oai
7f3597529c feat: add multi-agent v2 support under /feedback 2026-03-26 16:50:18 +01:00
9 changed files with 259 additions and 25 deletions

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).

View File

@@ -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)]

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;
@@ -719,18 +720,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 root_path = AgentPath::root();
let mut agents = Vec::with_capacity(live_agents.len().saturating_add(1));
@@ -780,6 +770,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
@@ -1072,6 +1079,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

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.

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 =

View File

@@ -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()),

View File

@@ -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

View File

@@ -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()),

View File

@@ -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