mirror of
https://github.com/openai/codex.git
synced 2026-02-02 15:03:38 +00:00
Compare commits
5 Commits
remove/doc
...
subagents-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
effb42efff | ||
|
|
f631bd6e8c | ||
|
|
d560aff1aa | ||
|
|
91cc56702f | ||
|
|
beb71f4a00 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -85,3 +85,5 @@ CHANGELOG.ignore.md
|
||||
# nix related
|
||||
.direnv
|
||||
.envrc
|
||||
|
||||
plans/
|
||||
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -1126,6 +1126,7 @@ dependencies = [
|
||||
"http",
|
||||
"image",
|
||||
"indexmap 2.12.0",
|
||||
"insta",
|
||||
"keyring",
|
||||
"landlock",
|
||||
"libc",
|
||||
@@ -3335,6 +3336,7 @@ checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0"
|
||||
dependencies = [
|
||||
"console",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"similar",
|
||||
]
|
||||
|
||||
|
||||
@@ -268,7 +268,8 @@ ignored = [
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
opt-level = "z" # or "s" (z is smaller)
|
||||
lto = "thin" # "fat" can be smaller sometimes; test both
|
||||
# Because we bundle some of these executables with the TypeScript CLI, we
|
||||
# remove everything to make the binary as small as possible.
|
||||
strip = "symbols"
|
||||
|
||||
@@ -121,6 +121,7 @@ image = { workspace = true, features = ["jpeg", "png"] }
|
||||
maplit = { workspace = true }
|
||||
predicates = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
insta = { version = "1.39", features = ["yaml"] }
|
||||
serial_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
22
codex-rs/core/root_agent_prompt.md
Normal file
22
codex-rs/core/root_agent_prompt.md
Normal file
@@ -0,0 +1,22 @@
|
||||
You are the **root agent** in a multi‑agent Codex session.
|
||||
|
||||
Your job is to solve the user’s task end‑to‑end. Use subagents as semi‑autonomous workers when that makes the work simpler, safer, or more parallel, and otherwise act directly in the conversation as a normal assistant.
|
||||
|
||||
Use subagents as follows:
|
||||
|
||||
- Spawn or fork a subagent when a piece of work can be isolated behind a clear prompt, or when you want an independent view on a problem.
|
||||
- Let subagents run independently. You do not need to keep generating output while they work; focus your own turns on planning, orchestration, and integrating results.
|
||||
- Use `subagent_send_message` to give a subagent follow-up instructions, send it short status updates or summaries, or interrupt and redirect it.
|
||||
- Use `subagent_await` when you need to wait for a particular subagent before continuing; you do not have to await every subagent you spawn, because they can also report progress and results to you via `subagent_send_message` and completions will be surfaced to you automatically.
|
||||
- When you see a `subagent_await` call/output injected into the transcript without you calling the tool, that came from the autosubmit path: the system drained the inbox (e.g., a subagent completion) while the root was idle and recorded a synthetic `subagent_await` so you can read and react without issuing the tool yourself.
|
||||
- Use `subagent_logs` when you only need to inspect what a subagent has been doing recently, not to change its state.
|
||||
- Use `subagent_list`, `subagent_prune`, and `subagent_cancel` to keep the set of active subagents small and relevant.
|
||||
- When you spawn a subagent or start a watchdog and there’s nothing else useful to do, issue the tool call right away and say you’re waiting for results (or for the watchdog to start). If you can do other useful work in parallel, do that instead of stalling, and only await when necessary.
|
||||
|
||||
Be concise and direct. Delegate multi‑step or long‑running work to subagents, summarize what they have done for the user, and always keep the conversation focused on the user’s goal.**
|
||||
|
||||
Example: long‑running supervision with a watchdog
|
||||
- Spawn a supervisor to own `PLAN.md`: e.g., `subagent_spawn` label `supervisor`, prompt it to keep the plan fresh, launch workers, and heartbeat every few minutes.
|
||||
- Attach a watchdog to the supervisor (or to yourself) that pings on a cadence and asks for progress: call `subagent_watchdog` with `{agent_id: <supervisor_id>, interval_s: 300, message: "Watchdog ping — report current status and PLAN progress", cancel: false}`.
|
||||
- The supervisor should reply to each ping with a brief status and, if needed, spawn/interrupt workers; the root can cancel or retarget by invoking `subagent_watchdog` again with `cancel: true`.
|
||||
- You can also set a self‑watchdog on the root agent to ensure you keep emitting status updates during multi‑hour tasks.***
|
||||
@@ -46,6 +46,7 @@ use mcp_types::ReadResourceRequestParams;
|
||||
use mcp_types::ReadResourceResult;
|
||||
use serde_json;
|
||||
use serde_json::Value;
|
||||
use tokio::fs;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::oneshot;
|
||||
@@ -103,6 +104,8 @@ use crate::shell;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::SessionServices;
|
||||
use crate::state::SessionState;
|
||||
use crate::subagents::SubagentManager;
|
||||
use crate::subagents::SubagentRegistry;
|
||||
use crate::tasks::GhostSnapshotTask;
|
||||
use crate::tasks::ReviewTask;
|
||||
use crate::tasks::SessionTask;
|
||||
@@ -134,12 +137,19 @@ use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
|
||||
// Built-in prompts for orchestrating and running subagents. These can be
|
||||
// overridden via files in `$CODEX_HOME` (see `load_root_agent_prompt` and
|
||||
// `load_subagent_prompt`).
|
||||
const ROOT_AGENT_PROMPT_FALLBACK: &str = include_str!("../root_agent_prompt.md");
|
||||
const SUBAGENT_PROMPT_FALLBACK: &str = include_str!("../subagent_prompt.md");
|
||||
|
||||
/// The high-level interface to the Codex system.
|
||||
/// It operates as a queue pair where you send submissions and receive events.
|
||||
pub struct Codex {
|
||||
pub(crate) next_id: AtomicU64,
|
||||
pub(crate) tx_sub: Sender<Submission>,
|
||||
pub(crate) rx_event: Receiver<Event>,
|
||||
pub(crate) conversation_id: ConversationId,
|
||||
}
|
||||
|
||||
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
|
||||
@@ -153,6 +163,31 @@ pub struct CodexSpawnOk {
|
||||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||
pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
async fn load_agent_prompt_fallback(fallback: &str, override_filename: &str) -> Option<String> {
|
||||
if let Ok(home) = crate::config::find_codex_home() {
|
||||
let path = home.join(override_filename);
|
||||
if let Ok(contents) = fs::read_to_string(&path).await {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
if fallback.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(fallback.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_root_agent_prompt() -> Option<String> {
|
||||
load_agent_prompt_fallback(ROOT_AGENT_PROMPT_FALLBACK, "AGENTS.root.md").await
|
||||
}
|
||||
|
||||
async fn load_subagent_prompt() -> Option<String> {
|
||||
load_agent_prompt_fallback(SUBAGENT_PROMPT_FALLBACK, "AGENTS.subagent.md").await
|
||||
}
|
||||
|
||||
impl Codex {
|
||||
/// Spawn a new [`Codex`] and initialize the session.
|
||||
pub async fn spawn(
|
||||
@@ -160,6 +195,7 @@ impl Codex {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
conversation_history: InitialHistory,
|
||||
session_source: SessionSource,
|
||||
desired_conversation_id: Option<ConversationId>,
|
||||
) -> CodexResult<CodexSpawnOk> {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
@@ -170,6 +206,27 @@ impl Codex {
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(format!("failed to load execpolicy: {err}")))?;
|
||||
|
||||
// When subagent tooling is enabled, attach additional developer
|
||||
// instructions that clarify the root vs subagent responsibilities.
|
||||
// Exactly one of these prompts applies to a session:
|
||||
// - Root sessions get `root_agent_prompt`.
|
||||
// - Subagent sessions (spawned or forked) get `subagent_prompt`.
|
||||
let role_prompt = if config.features.enabled(Feature::SubagentTools) {
|
||||
if let SessionSource::SubAgent(_) = session_source {
|
||||
load_subagent_prompt().await
|
||||
} else {
|
||||
load_root_agent_prompt().await
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let developer_instructions = match (role_prompt, config.developer_instructions.clone()) {
|
||||
(None, existing) => existing,
|
||||
(Some(prompt), None) => Some(prompt),
|
||||
(Some(prompt), Some(existing)) => Some(format!("{prompt}\n\n{existing}")),
|
||||
};
|
||||
|
||||
let config = Arc::new(config);
|
||||
|
||||
let session_configuration = SessionConfiguration {
|
||||
@@ -177,7 +234,7 @@ impl Codex {
|
||||
model: config.model.clone(),
|
||||
model_reasoning_effort: config.model_reasoning_effort,
|
||||
model_reasoning_summary: config.model_reasoning_summary,
|
||||
developer_instructions: config.developer_instructions.clone(),
|
||||
developer_instructions,
|
||||
user_instructions,
|
||||
base_instructions: config.base_instructions.clone(),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
@@ -199,6 +256,7 @@ impl Codex {
|
||||
tx_event.clone(),
|
||||
conversation_history,
|
||||
session_source_clone,
|
||||
desired_conversation_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -213,6 +271,7 @@ impl Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
rx_event,
|
||||
conversation_id,
|
||||
};
|
||||
|
||||
Ok(CodexSpawnOk {
|
||||
@@ -221,6 +280,10 @@ impl Codex {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn conversation_id(&self) -> ConversationId {
|
||||
self.conversation_id
|
||||
}
|
||||
|
||||
/// Submit the `op` wrapped in a `Submission` with a unique ID.
|
||||
pub async fn submit(&self, op: Op) -> CodexResult<String> {
|
||||
let id = self
|
||||
@@ -387,6 +450,22 @@ pub(crate) struct SessionSettingsUpdate {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn conversation_id(&self) -> ConversationId {
|
||||
self.conversation_id
|
||||
}
|
||||
|
||||
pub(crate) async fn history_len(&self) -> usize {
|
||||
let mut history = {
|
||||
let state = self.state.lock().await;
|
||||
state.clone_history()
|
||||
};
|
||||
history.get_history().len()
|
||||
}
|
||||
|
||||
pub(crate) async fn has_active_turn(&self) -> bool {
|
||||
self.active_turn.lock().await.is_some()
|
||||
}
|
||||
|
||||
fn make_turn_context(
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
otel_event_manager: &OtelEventManager,
|
||||
@@ -455,6 +534,7 @@ impl Session {
|
||||
tx_event: Sender<Event>,
|
||||
initial_history: InitialHistory,
|
||||
session_source: SessionSource,
|
||||
desired_conversation_id: Option<ConversationId>,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
debug!(
|
||||
"Configuring session: model={}; provider={:?}",
|
||||
@@ -469,7 +549,7 @@ impl Session {
|
||||
|
||||
let (conversation_id, rollout_params) = match &initial_history {
|
||||
InitialHistory::New | InitialHistory::Forked(_) => {
|
||||
let conversation_id = ConversationId::default();
|
||||
let conversation_id = desired_conversation_id.unwrap_or_default();
|
||||
(
|
||||
conversation_id,
|
||||
RolloutRecorderParams::new(
|
||||
@@ -553,6 +633,15 @@ impl Session {
|
||||
// Create the mutable state for the Session.
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
|
||||
let subagent_registry = SubagentRegistry::new();
|
||||
let subagent_manager = SubagentManager::new(
|
||||
Arc::new(subagent_registry.clone()),
|
||||
config.max_active_subagents,
|
||||
config.root_agent_uses_user_messages,
|
||||
config.subagent_root_inbox_autosubmit,
|
||||
config.subagent_inbox_inject_before_tools,
|
||||
);
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
@@ -564,6 +653,8 @@ impl Session {
|
||||
auth_manager: Arc::clone(&auth_manager),
|
||||
otel_event_manager,
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
subagents: subagent_registry,
|
||||
subagent_manager,
|
||||
};
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
@@ -575,6 +666,9 @@ impl Session {
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
// Register this session so it can be discovered for fork-time subagent reparenting.
|
||||
crate::session_index::register(conversation_id, &sess);
|
||||
|
||||
// Dispatch the SessionConfiguredEvent first and then report any errors.
|
||||
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
|
||||
let initial_messages = initial_history.get_event_msgs();
|
||||
@@ -652,10 +746,13 @@ impl Session {
|
||||
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
|
||||
self.flush_rollout().await;
|
||||
}
|
||||
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => {
|
||||
InitialHistory::Resumed(_) => {
|
||||
let rollout_items = conversation_history.get_rollout_items();
|
||||
let persist = matches!(conversation_history, InitialHistory::Forked(_));
|
||||
|
||||
// Rehydrate subagent registry from persisted lifecycle events so UI/list tools work after resume.
|
||||
self.services
|
||||
.subagent_manager
|
||||
.import_from_rollout(&rollout_items, self.conversation_id)
|
||||
.await;
|
||||
// If resuming, warn when the last recorded model differs from the current one.
|
||||
if let InitialHistory::Resumed(_) = conversation_history
|
||||
&& let Some(prev) = rollout_items.iter().rev().find_map(|it| {
|
||||
@@ -692,11 +789,34 @@ impl Session {
|
||||
.await;
|
||||
}
|
||||
|
||||
// If persisting, persist all rollout items as-is (recorder filters)
|
||||
if persist && !rollout_items.is_empty() {
|
||||
self.flush_rollout().await;
|
||||
}
|
||||
InitialHistory::Forked(_) => {
|
||||
let rollout_items = conversation_history.get_rollout_items();
|
||||
|
||||
// Start from the parent rollout and then, for subagent
|
||||
// sessions only, append a developer message carrying the
|
||||
// subagent-specific prompt so the child can see it at the
|
||||
// fork boundary.
|
||||
let mut reconstructed_history =
|
||||
self.reconstruct_history_from_rollout(&turn_context, &rollout_items);
|
||||
|
||||
if let Some(dev) = turn_context.developer_instructions.as_deref()
|
||||
&& !dev.trim().is_empty()
|
||||
{
|
||||
let dev_item: ResponseItem = DeveloperInstructions::new(dev.to_string()).into();
|
||||
reconstructed_history.push(dev_item);
|
||||
}
|
||||
|
||||
if !reconstructed_history.is_empty() {
|
||||
self.record_into_history(&reconstructed_history, &turn_context)
|
||||
.await;
|
||||
}
|
||||
|
||||
if !rollout_items.is_empty() {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
}
|
||||
// Flush after seeding history and any persisted rollout copy.
|
||||
|
||||
self.flush_rollout().await;
|
||||
}
|
||||
}
|
||||
@@ -1339,6 +1459,12 @@ impl Session {
|
||||
async fn cancel_mcp_startup(&self) {
|
||||
self.services.mcp_startup_cancellation_token.cancel();
|
||||
}
|
||||
|
||||
pub(crate) fn root_inbox_autosubmit_enabled(&self) -> bool {
|
||||
self.services
|
||||
.subagent_manager
|
||||
.root_inbox_autosubmit_enabled()
|
||||
}
|
||||
}
|
||||
|
||||
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
|
||||
@@ -1829,23 +1955,43 @@ async fn spawn_review_thread(
|
||||
/// - If the model sends only an assistant message, we record it in the
|
||||
/// conversation history and consider the task complete.
|
||||
///
|
||||
fn merge_turn_items_for_recording(
|
||||
outputs_to_record: &[ResponseItem],
|
||||
new_inputs_to_record: &[ResponseItem],
|
||||
inbox_items: &[ResponseItem],
|
||||
inject_before_tools: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut items = Vec::with_capacity(
|
||||
outputs_to_record.len() + new_inputs_to_record.len() + inbox_items.len(),
|
||||
);
|
||||
items.extend(outputs_to_record.iter().cloned());
|
||||
if inject_before_tools {
|
||||
items.extend(inbox_items.iter().cloned());
|
||||
items.extend(new_inputs_to_record.iter().cloned());
|
||||
} else {
|
||||
items.extend(new_inputs_to_record.iter().cloned());
|
||||
items.extend(inbox_items.iter().cloned());
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
pub(crate) async fn run_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
input: Vec<UserInput>,
|
||||
record_user_input: bool,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
if input.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let event = EventMsg::TaskStarted(TaskStartedEvent {
|
||||
model_context_window: turn_context.client.get_model_context_window(),
|
||||
});
|
||||
sess.send_event(&turn_context, event).await;
|
||||
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
sess.record_input_and_rollout_usermsg(turn_context.as_ref(), &initial_input_for_turn)
|
||||
.await;
|
||||
if record_user_input && !input.is_empty() {
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
sess.record_input_and_rollout_usermsg(turn_context.as_ref(), &initial_input_for_turn)
|
||||
.await;
|
||||
}
|
||||
|
||||
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
|
||||
.await;
|
||||
@@ -1904,8 +2050,36 @@ pub(crate) async fn run_task(
|
||||
let token_limit_reached = total_usage_tokens
|
||||
.map(|tokens| tokens >= limit)
|
||||
.unwrap_or(false);
|
||||
let (responses, items_to_record_in_conversation_history) =
|
||||
process_items(processed_items, &sess, &turn_context).await;
|
||||
let (responses, outputs_to_record, new_inputs_to_record) =
|
||||
process_items(processed_items).await;
|
||||
|
||||
// Drain the root agent inbox and synthesize subagent_await
|
||||
// items at this safe stopping point. Only the root session has
|
||||
// a root inbox; for subagent sessions this will be a no-op.
|
||||
let inbox_items = sess
|
||||
.services
|
||||
.subagent_manager
|
||||
.drain_root_inbox_to_items(&sess.conversation_id())
|
||||
.await;
|
||||
let had_inbox = !inbox_items.is_empty();
|
||||
|
||||
// Assemble the final list of items to record for this turn,
|
||||
// respecting the configured injection order for inbox-derived
|
||||
// synthetic subagent_await calls.
|
||||
let items_to_record_in_conversation_history = merge_turn_items_for_recording(
|
||||
&outputs_to_record,
|
||||
&new_inputs_to_record,
|
||||
&inbox_items,
|
||||
sess.services.subagent_manager.inbox_inject_before_tools(),
|
||||
);
|
||||
|
||||
if !items_to_record_in_conversation_history.is_empty() {
|
||||
sess.record_conversation_items(
|
||||
&turn_context,
|
||||
&items_to_record_in_conversation_history,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
|
||||
if token_limit_reached {
|
||||
@@ -1919,6 +2093,15 @@ pub(crate) async fn run_task(
|
||||
}
|
||||
|
||||
if responses.is_empty() {
|
||||
// Hard case: no tool calls to execute. If draining the
|
||||
// inbox produced new synthetic subagent_await items and
|
||||
// autosubmit is enabled, allow the model to react in a
|
||||
// follow-up turn by continuing the loop instead of
|
||||
// treating this as a terminal completion.
|
||||
if sess.root_inbox_autosubmit_enabled() && had_inbox {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_agent_message = get_last_assistant_message_from_turn(
|
||||
&items_to_record_in_conversation_history,
|
||||
);
|
||||
@@ -1937,7 +2120,18 @@ pub(crate) async fn run_task(
|
||||
Err(CodexErr::TurnAborted {
|
||||
dangling_artifacts: processed_items,
|
||||
}) => {
|
||||
let _ = process_items(processed_items, &sess, &turn_context).await;
|
||||
let (_, outputs_to_record, new_inputs_to_record) =
|
||||
process_items(processed_items).await;
|
||||
let mut items_to_record_in_conversation_history = Vec::new();
|
||||
items_to_record_in_conversation_history.extend(outputs_to_record);
|
||||
items_to_record_in_conversation_history.extend(new_inputs_to_record);
|
||||
if !items_to_record_in_conversation_history.is_empty() {
|
||||
sess.record_conversation_items(
|
||||
&turn_context,
|
||||
&items_to_record_in_conversation_history,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// Aborted turn is reported via a different event.
|
||||
break;
|
||||
}
|
||||
@@ -2410,6 +2604,11 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::subagents::AwaitInboxResult;
|
||||
use crate::subagents::InboxMessage;
|
||||
use crate::subagents::SubagentCompletion;
|
||||
use crate::subagents::SubagentManager;
|
||||
use crate::subagents::SubagentRegistry;
|
||||
use mcp_types::ContentBlock;
|
||||
use mcp_types::TextContent;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -2461,6 +2660,66 @@ mod tests {
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forked_subagent_injects_subagent_developer_instructions() {
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem as ProtocolResponseItem;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
|
||||
// Start from a basic session and then mark it as a subagent session
|
||||
// with explicit developer instructions that stand in for the
|
||||
// subagent prompt.
|
||||
let (session, _tc) = make_session_and_context();
|
||||
tokio_test::block_on(async {
|
||||
let mut state = session.state.lock().await;
|
||||
state.session_configuration.session_source =
|
||||
SessionSource::SubAgent(SubAgentSource::Other("child".to_string()));
|
||||
state.session_configuration.developer_instructions =
|
||||
Some("SUBAGENT_PROMPT".to_string());
|
||||
});
|
||||
|
||||
// Build a minimal forked rollout containing a single user message
|
||||
// from the parent.
|
||||
let parent_msg = ProtocolResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "parent-msg".to_string(),
|
||||
}],
|
||||
};
|
||||
let rollout_items = vec![RolloutItem::ResponseItem(parent_msg)];
|
||||
|
||||
// Seed the forked history; for subagent sessions this should append a
|
||||
// developer message carrying the subagent prompt after the parent
|
||||
// transcript.
|
||||
tokio_test::block_on(session.record_initial_history(InitialHistory::Forked(rollout_items)));
|
||||
|
||||
let history = tokio_test::block_on(async {
|
||||
session.state.lock().await.clone_history().get_history()
|
||||
});
|
||||
|
||||
// Parent message should still be present.
|
||||
assert!(history.iter().any(|item| matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user" && content.iter().any(|c| matches!(
|
||||
c,
|
||||
ContentItem::InputText { text } if text == "parent-msg"
|
||||
))
|
||||
)));
|
||||
|
||||
// Subagent developer instructions should be appended as a `developer`
|
||||
// role message containing the configured prompt text.
|
||||
assert!(history.iter().any(|item| matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "developer" && content.iter().any(|c| matches!(
|
||||
c,
|
||||
ContentItem::InputText { text } if text == "SUBAGENT_PROMPT"
|
||||
))
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_structured_content_when_present() {
|
||||
let ctr = CallToolResult {
|
||||
@@ -2621,6 +2880,15 @@ mod tests {
|
||||
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
|
||||
let subagent_registry = SubagentRegistry::new();
|
||||
let subagent_manager = SubagentManager::new(
|
||||
Arc::new(subagent_registry.clone()),
|
||||
config.max_active_subagents,
|
||||
config.root_agent_uses_user_messages,
|
||||
config.subagent_root_inbox_autosubmit,
|
||||
config.subagent_inbox_inject_before_tools,
|
||||
);
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
@@ -2632,6 +2900,8 @@ mod tests {
|
||||
auth_manager: Arc::clone(&auth_manager),
|
||||
otel_event_manager: otel_event_manager.clone(),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
subagents: subagent_registry,
|
||||
subagent_manager,
|
||||
};
|
||||
|
||||
let turn_context = Session::make_turn_context(
|
||||
@@ -2699,6 +2969,15 @@ mod tests {
|
||||
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
|
||||
let subagent_registry = SubagentRegistry::new();
|
||||
let subagent_manager = SubagentManager::new(
|
||||
Arc::new(subagent_registry.clone()),
|
||||
config.max_active_subagents,
|
||||
config.root_agent_uses_user_messages,
|
||||
config.subagent_root_inbox_autosubmit,
|
||||
config.subagent_inbox_inject_before_tools,
|
||||
);
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
@@ -2710,6 +2989,8 @@ mod tests {
|
||||
auth_manager: Arc::clone(&auth_manager),
|
||||
otel_event_manager: otel_event_manager.clone(),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
subagents: subagent_registry,
|
||||
subagent_manager,
|
||||
};
|
||||
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
@@ -2733,6 +3014,195 @@ mod tests {
|
||||
(session, turn_context, rx_event)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_inbox_tool_only_mode_injects_await_into_parent_and_child() {
|
||||
// Build parent and child sessions and register them so the
|
||||
// SubagentManager can look them up via session_index.
|
||||
let (parent_session_raw, _tc_parent) = make_session_and_context();
|
||||
let parent_session = Arc::new(parent_session_raw);
|
||||
let (child_session_raw, _tc_child) = make_session_and_context();
|
||||
let child_session = Arc::new(child_session_raw);
|
||||
|
||||
crate::session_index::register(parent_session.conversation_id(), &parent_session);
|
||||
crate::session_index::register(child_session.conversation_id(), &child_session);
|
||||
|
||||
// Independent registry/manager used only for this test so we can
|
||||
// construct metadata and an AwaitInboxResult by hand.
|
||||
let registry = Arc::new(SubagentRegistry::new());
|
||||
let manager = SubagentManager::new(Arc::clone(®istry), 4, false, false, false);
|
||||
|
||||
let agent_id = 1;
|
||||
let initial_message_count = 0;
|
||||
let metadata = registry
|
||||
.register_spawn(
|
||||
child_session.conversation_id(),
|
||||
Some(parent_session.conversation_id()),
|
||||
Some(agent_id),
|
||||
agent_id,
|
||||
initial_message_count,
|
||||
Some("child".to_string()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let messages = vec![InboxMessage {
|
||||
sender_agent_id: 0,
|
||||
recipient_agent_id: agent_id,
|
||||
interrupt: false,
|
||||
prompt: Some("hello child".to_string()),
|
||||
timestamp_ms: 1_000,
|
||||
}];
|
||||
|
||||
let await_result = AwaitInboxResult {
|
||||
metadata,
|
||||
completion: Some(SubagentCompletion::Completed {
|
||||
last_message: Some("done".to_string()),
|
||||
}),
|
||||
messages,
|
||||
};
|
||||
|
||||
manager
|
||||
.deliver_inbox_to_threads_at_yield(&await_result)
|
||||
.await;
|
||||
|
||||
// Child history should contain a synthetic subagent_await call in
|
||||
// tool-only mode.
|
||||
let child_history = child_session.clone_history().await.get_history();
|
||||
assert!(child_history.iter().any(|item| matches!(
|
||||
item,
|
||||
ResponseItem::FunctionCall { name, .. } if name == "subagent_await"
|
||||
)));
|
||||
|
||||
// And the user-visible payload should include the original prompt.
|
||||
assert!(child_history.iter().any(|item| match item {
|
||||
ResponseItem::FunctionCallOutput { output, .. } => {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&output.content) else {
|
||||
return false;
|
||||
};
|
||||
value["messages"]
|
||||
.as_array()
|
||||
.is_some_and(|msgs| msgs.iter().any(|m| m["prompt"] == "hello child"))
|
||||
}
|
||||
_ => false,
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_inbox_root_messages_become_user_turns_when_toggle_enabled() {
|
||||
use codex_protocol::models::ContentItem;
|
||||
|
||||
let (parent_session_raw, _tc_parent) = make_session_and_context();
|
||||
let parent_session = Arc::new(parent_session_raw);
|
||||
let (child_session_raw, _tc_child) = make_session_and_context();
|
||||
let child_session = Arc::new(child_session_raw);
|
||||
|
||||
crate::session_index::register(parent_session.conversation_id(), &parent_session);
|
||||
crate::session_index::register(child_session.conversation_id(), &child_session);
|
||||
|
||||
let registry = Arc::new(SubagentRegistry::new());
|
||||
let manager = SubagentManager::new(Arc::clone(®istry), 4, true, false, false);
|
||||
|
||||
let agent_id = 1;
|
||||
let initial_message_count = 0;
|
||||
let metadata = registry
|
||||
.register_spawn(
|
||||
child_session.conversation_id(),
|
||||
Some(parent_session.conversation_id()),
|
||||
Some(agent_id),
|
||||
agent_id,
|
||||
initial_message_count,
|
||||
Some("child".to_string()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let messages = vec![InboxMessage {
|
||||
sender_agent_id: 0,
|
||||
recipient_agent_id: agent_id,
|
||||
interrupt: false,
|
||||
prompt: Some("hello child".to_string()),
|
||||
timestamp_ms: 1_000,
|
||||
}];
|
||||
|
||||
let await_result = AwaitInboxResult {
|
||||
metadata,
|
||||
completion: Some(SubagentCompletion::Completed {
|
||||
last_message: Some("done".to_string()),
|
||||
}),
|
||||
messages,
|
||||
};
|
||||
|
||||
manager
|
||||
.deliver_inbox_to_threads_at_yield(&await_result)
|
||||
.await;
|
||||
|
||||
// In toggle-on mode, the child should still see a synthetic
|
||||
// subagent_await reflecting completion, even when the only inbox
|
||||
// messages came from the root.
|
||||
let child_history = child_session.clone_history().await.get_history();
|
||||
assert!(child_history.iter().any(|item| matches!(
|
||||
item,
|
||||
ResponseItem::FunctionCall { name, .. } if name == "subagent_await"
|
||||
)));
|
||||
|
||||
// Instead, the root-origin prompt should appear as a user message.
|
||||
assert!(child_history.iter().any(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
content.iter().any(|c| match c {
|
||||
ContentItem::InputText { text } => text == "hello child",
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_completion_without_messages_surfaces_await_to_parent_and_child() {
|
||||
let (parent_session_raw, _tc_parent) = make_session_and_context();
|
||||
let parent_session = Arc::new(parent_session_raw);
|
||||
let (child_session_raw, _tc_child) = make_session_and_context();
|
||||
let child_session = Arc::new(child_session_raw);
|
||||
|
||||
crate::session_index::register(parent_session.conversation_id(), &parent_session);
|
||||
crate::session_index::register(child_session.conversation_id(), &child_session);
|
||||
|
||||
let registry = Arc::new(SubagentRegistry::new());
|
||||
let manager = SubagentManager::new(Arc::clone(®istry), 4, false, false, false);
|
||||
|
||||
let agent_id = 1;
|
||||
let initial_message_count = 0;
|
||||
let metadata = registry
|
||||
.register_spawn(
|
||||
child_session.conversation_id(),
|
||||
Some(parent_session.conversation_id()),
|
||||
Some(agent_id),
|
||||
agent_id,
|
||||
initial_message_count,
|
||||
Some("child".to_string()),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let await_result = AwaitInboxResult {
|
||||
metadata,
|
||||
completion: Some(SubagentCompletion::Completed {
|
||||
last_message: Some("done".to_string()),
|
||||
}),
|
||||
messages: Vec::new(),
|
||||
};
|
||||
|
||||
manager
|
||||
.deliver_inbox_to_threads_at_yield(&await_result)
|
||||
.await;
|
||||
|
||||
let child_history = child_session.clone_history().await.get_history();
|
||||
assert!(child_history.iter().any(|item| matches!(
|
||||
item,
|
||||
ResponseItem::FunctionCall { name, .. } if name == "subagent_await"
|
||||
)));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NeverEndingTask {
|
||||
kind: TaskKind,
|
||||
@@ -3209,4 +3679,90 @@ mod tests {
|
||||
|
||||
pretty_assertions::assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_turn_items_orders_inbox_after_tools_by_default() {
|
||||
let outputs = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "tool-output".to_string(),
|
||||
}],
|
||||
}];
|
||||
let new_inputs = vec![ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "tool-response".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
let inbox = vec![ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "subagent_await".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "await-1".to_string(),
|
||||
}];
|
||||
|
||||
let merged = merge_turn_items_for_recording(&outputs, &new_inputs, &inbox, false);
|
||||
|
||||
assert_eq!(merged.len(), 3);
|
||||
// outputs_to_record first
|
||||
assert!(matches!(
|
||||
&merged[0],
|
||||
ResponseItem::Message { role, .. } if role == "assistant"
|
||||
));
|
||||
// new_inputs_to_record next
|
||||
assert!(matches!(
|
||||
&merged[1],
|
||||
ResponseItem::FunctionCallOutput { call_id, .. } if call_id == "call-1"
|
||||
));
|
||||
// inbox-derived synthetic await last
|
||||
assert!(matches!(
|
||||
&merged[2],
|
||||
ResponseItem::FunctionCall { name, .. } if name == "subagent_await"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_turn_items_orders_inbox_before_tools_when_configured() {
|
||||
let outputs = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "tool-output".to_string(),
|
||||
}],
|
||||
}];
|
||||
let new_inputs = vec![ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "tool-response".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
}];
|
||||
let inbox = vec![ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "subagent_await".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "await-1".to_string(),
|
||||
}];
|
||||
|
||||
let merged = merge_turn_items_for_recording(&outputs, &new_inputs, &inbox, true);
|
||||
|
||||
assert_eq!(merged.len(), 3);
|
||||
// outputs_to_record first
|
||||
assert!(matches!(
|
||||
&merged[0],
|
||||
ResponseItem::Message { role, .. } if role == "assistant"
|
||||
));
|
||||
// inbox-derived synthetic await next
|
||||
assert!(matches!(
|
||||
&merged[1],
|
||||
ResponseItem::FunctionCall { name, .. } if name == "subagent_await"
|
||||
));
|
||||
// new_inputs_to_record last
|
||||
assert!(matches!(
|
||||
&merged[2],
|
||||
ResponseItem::FunctionCallOutput { call_id, .. } if call_id == "call-1"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::atomic::AtomicU64;
|
||||
use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -30,13 +31,16 @@ use codex_protocol::protocol::InitialHistory;
|
||||
/// The returned `events_rx` yields non-approval events emitted by the sub-agent.
|
||||
/// Approval requests are handled via `parent_session` and are not surfaced.
|
||||
/// The returned `ops_tx` allows the caller to submit additional `Op`s to the sub-agent.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn run_codex_conversation_interactive(
|
||||
config: Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
parent_session: Arc<Session>,
|
||||
parent_ctx: Arc<TurnContext>,
|
||||
cancel_token: CancellationToken,
|
||||
desired_conversation_id: Option<ConversationId>,
|
||||
initial_history: Option<InitialHistory>,
|
||||
source: SubAgentSource,
|
||||
) -> Result<Codex, CodexErr> {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
@@ -45,7 +49,8 @@ pub(crate) async fn run_codex_conversation_interactive(
|
||||
config,
|
||||
auth_manager,
|
||||
initial_history.unwrap_or(InitialHistory::New),
|
||||
SessionSource::SubAgent(SubAgentSource::Review),
|
||||
SessionSource::SubAgent(source),
|
||||
desired_conversation_id,
|
||||
)
|
||||
.await?;
|
||||
let codex = Arc::new(codex);
|
||||
@@ -81,6 +86,7 @@ pub(crate) async fn run_codex_conversation_interactive(
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub: tx_ops,
|
||||
rx_event: rx_sub,
|
||||
conversation_id: codex.conversation_id(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,13 +111,16 @@ pub(crate) async fn run_codex_conversation_one_shot(
|
||||
parent_session,
|
||||
parent_ctx,
|
||||
child_cancel.clone(),
|
||||
None,
|
||||
initial_history,
|
||||
SubAgentSource::Review,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Send the initial input to kick off the one-shot turn.
|
||||
io.submit(Op::UserInput { items: input }).await?;
|
||||
|
||||
let conversation_id = io.conversation_id();
|
||||
// Bridge events so we can observe completion and shut down automatically.
|
||||
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let ops_tx = io.tx_sub.clone();
|
||||
@@ -146,6 +155,7 @@ pub(crate) async fn run_codex_conversation_one_shot(
|
||||
next_id: AtomicU64::new(0),
|
||||
rx_event: rx_bridge,
|
||||
tx_sub: tx_closed,
|
||||
conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::profile::ConfigProfile;
|
||||
use toml::Value as TomlValue;
|
||||
@@ -70,6 +71,10 @@ pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.1-codex";
|
||||
/// the context window.
|
||||
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
|
||||
pub(crate) const DEFAULT_MAX_ACTIVE_SUBAGENTS: usize = 8;
|
||||
pub(crate) const MIN_MAX_ACTIVE_SUBAGENTS: usize = 1;
|
||||
pub(crate) const MAX_MAX_ACTIVE_SUBAGENTS: usize = 64;
|
||||
|
||||
pub(crate) const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
@@ -104,6 +109,9 @@ pub struct Config {
|
||||
/// for either of approval_policy or sandbox_mode.
|
||||
pub did_user_set_custom_approval_policy_or_sandbox_mode: bool,
|
||||
|
||||
/// Maximum number of concurrently active subagents allowed in a session.
|
||||
pub max_active_subagents: usize,
|
||||
|
||||
/// On Windows, indicates that a previously configured workspace-write sandbox
|
||||
/// was coerced to read-only because native auto mode is unsupported.
|
||||
pub forced_auto_mode_downgraded_on_windows: bool,
|
||||
@@ -128,6 +136,30 @@ pub struct Config {
|
||||
/// Developer instructions override injected as a separate message.
|
||||
pub developer_instructions: Option<String>,
|
||||
|
||||
/// When true, messages from the root agent to a subagent should be
|
||||
/// surfaced as `user` role messages in the child’s history instead of
|
||||
/// relying solely on tool calls and inbox semantics. This is useful for
|
||||
/// evaluations that compare direct user-style turns versus tool-mediated
|
||||
/// messaging. When false, root-to-child communication is modeled purely
|
||||
/// via tools and the subagent inbox.
|
||||
pub root_agent_uses_user_messages: bool,
|
||||
|
||||
/// When true, the root agent will, at turn boundaries, drain subagent
|
||||
/// inboxes and inject synthetic `subagent_await` calls + outputs into the
|
||||
/// message stream, and may auto-start a new turn when idle. When false,
|
||||
/// subagent inboxes are only surfaced when explicitly awaited or at
|
||||
/// subagent-specific yield points.
|
||||
pub subagent_root_inbox_autosubmit: bool,
|
||||
|
||||
/// Controls where synthetic `subagent_await` tool calls and outputs for
|
||||
/// inbox delivery are injected relative to real tool call outputs inside a
|
||||
/// turn. When true, inbox-derived `subagent_await` items are recorded
|
||||
/// *before* tool outputs (Option B: closer to chronological ordering). When
|
||||
/// false (default), they are recorded *after* tool outputs (Option A:
|
||||
/// closer to training-time behavior where the model generally sees its own
|
||||
/// tool call and result before additional context).
|
||||
pub subagent_inbox_inject_before_tools: bool,
|
||||
|
||||
/// Compact prompt override.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
@@ -245,6 +277,7 @@ pub struct Config {
|
||||
pub experimental_sandbox_command_assessment: bool,
|
||||
|
||||
/// If set to `true`, used only the experimental unified exec tool.
|
||||
#[allow(dead_code)]
|
||||
pub use_experimental_unified_exec_tool: bool,
|
||||
|
||||
/// If set to `true`, use the experimental official Rust MCP client.
|
||||
@@ -596,6 +629,30 @@ pub struct ConfigToml {
|
||||
/// Compact prompt used for history compaction.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
/// When true, messages from the root agent to subagents should be
|
||||
/// represented as `user` role messages in the child’s history. When
|
||||
/// false or unset, root-to-child communication is modeled purely via
|
||||
/// `subagent_send_message` and inbox delivery.
|
||||
#[serde(default)]
|
||||
pub root_agent_uses_user_messages: Option<bool>,
|
||||
|
||||
/// When true, the root agent drains subagent inboxes at turn boundaries
|
||||
/// and may auto-start new turns when idle. When false or unset, the root
|
||||
/// only observes subagent inboxes via explicit `subagent_await` calls or
|
||||
/// subagent-driven yield points.
|
||||
#[serde(default)]
|
||||
pub subagent_root_inbox_autosubmit: Option<bool>,
|
||||
|
||||
/// When true, inbox-derived `subagent_await` calls and outputs are
|
||||
/// injected *before* tool outputs inside a turn (Option B, closer to
|
||||
/// strict chronological ordering). When false or unset, synthetic\n /// `subagent_await` entries are injected *after* tool outputs (Option A,
|
||||
/// closer to training-time patterns where the model generally sees its own
|
||||
/// tool call and result before extra context).\n #[serde(default)]
|
||||
pub subagent_inbox_inject_before_tools: Option<bool>,
|
||||
|
||||
/// Maximum number of concurrently active subagents allowed in a session.
|
||||
pub max_active_subagents: Option<usize>,
|
||||
|
||||
/// When set, restricts ChatGPT login to a specific workspace identifier.
|
||||
#[serde(default)]
|
||||
pub forced_chatgpt_workspace_id: Option<String>,
|
||||
@@ -889,6 +946,10 @@ pub struct ConfigOverrides {
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
pub compact_prompt: Option<String>,
|
||||
pub max_active_subagents: Option<usize>,
|
||||
pub root_agent_uses_user_messages: Option<bool>,
|
||||
pub subagent_root_inbox_autosubmit: Option<bool>,
|
||||
pub subagent_inbox_inject_before_tools: Option<bool>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
pub tools_web_search_request: Option<bool>,
|
||||
@@ -926,6 +987,33 @@ pub fn resolve_oss_provider(
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Clone the existing config with a model override, re-deriving any model-specific fields.
|
||||
pub fn clone_with_model_override(&self, model: &str) -> std::io::Result<Self> {
|
||||
if model.trim().is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"model cannot be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let mut cfg = self.clone();
|
||||
cfg.model = model.trim().to_string();
|
||||
|
||||
let model_family = find_family_for_model(&cfg.model)
|
||||
.unwrap_or_else(|| derive_default_model_family(&cfg.model));
|
||||
cfg.model_family = model_family;
|
||||
|
||||
if let Some(info) = get_model_info(&cfg.model_family) {
|
||||
cfg.model_context_window = Some(info.context_window);
|
||||
cfg.model_auto_compact_token_limit = info.auto_compact_token_limit;
|
||||
} else {
|
||||
cfg.model_context_window = None;
|
||||
cfg.model_auto_compact_token_limit = None;
|
||||
}
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Meant to be used exclusively for tests: `load_with_overrides()` should
|
||||
/// be used in all other cases.
|
||||
pub fn load_from_base_config_with_overrides(
|
||||
@@ -948,6 +1036,10 @@ impl Config {
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
compact_prompt,
|
||||
max_active_subagents,
|
||||
root_agent_uses_user_messages,
|
||||
subagent_root_inbox_autosubmit: _,
|
||||
subagent_inbox_inject_before_tools: _,
|
||||
include_apply_patch_tool: include_apply_patch_tool_override,
|
||||
show_raw_agent_reasoning,
|
||||
tools_web_search_request: override_tools_web_search_request,
|
||||
@@ -1080,6 +1172,7 @@ impl Config {
|
||||
|
||||
let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform);
|
||||
let tools_web_search_request = features.enabled(Feature::WebSearchRequest);
|
||||
#[allow(dead_code)]
|
||||
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
|
||||
let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient);
|
||||
let experimental_sandbox_command_assessment =
|
||||
@@ -1162,6 +1255,37 @@ impl Config {
|
||||
.or(cfg.review_model)
|
||||
.unwrap_or_else(default_review_model);
|
||||
|
||||
let raw_max_active_subagents = max_active_subagents
|
||||
.or(config_profile.max_active_subagents)
|
||||
.or(cfg.max_active_subagents)
|
||||
.unwrap_or(DEFAULT_MAX_ACTIVE_SUBAGENTS);
|
||||
|
||||
if raw_max_active_subagents < MIN_MAX_ACTIVE_SUBAGENTS {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"max_active_subagents must be at least {MIN_MAX_ACTIVE_SUBAGENTS}, got {raw_max_active_subagents}"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let max_active_subagents = if raw_max_active_subagents > MAX_MAX_ACTIVE_SUBAGENTS {
|
||||
warn!(
|
||||
"max_active_subagents clamped from {} to {}",
|
||||
raw_max_active_subagents, MAX_MAX_ACTIVE_SUBAGENTS
|
||||
);
|
||||
MAX_MAX_ACTIVE_SUBAGENTS
|
||||
} else {
|
||||
raw_max_active_subagents
|
||||
};
|
||||
|
||||
let root_agent_uses_user_messages = root_agent_uses_user_messages
|
||||
.or(cfg.root_agent_uses_user_messages)
|
||||
.unwrap_or(true);
|
||||
let subagent_root_inbox_autosubmit = cfg.subagent_root_inbox_autosubmit.unwrap_or(true);
|
||||
let subagent_inbox_inject_before_tools =
|
||||
cfg.subagent_inbox_inject_before_tools.unwrap_or(false);
|
||||
|
||||
let config = Self {
|
||||
model,
|
||||
review_model,
|
||||
@@ -1180,6 +1304,9 @@ impl Config {
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
root_agent_uses_user_messages,
|
||||
subagent_root_inbox_autosubmit,
|
||||
subagent_inbox_inject_before_tools,
|
||||
compact_prompt,
|
||||
// The config.toml omits "_mode" because it's a config file. However, "_mode"
|
||||
// is important in code to differentiate the mode from the store implementation.
|
||||
@@ -1214,6 +1341,7 @@ impl Config {
|
||||
.show_raw_agent_reasoning
|
||||
.or(show_raw_agent_reasoning)
|
||||
.unwrap_or(false),
|
||||
max_active_subagents,
|
||||
model_reasoning_effort: config_profile
|
||||
.model_reasoning_effort
|
||||
.or(cfg.model_reasoning_effort),
|
||||
@@ -1611,6 +1739,73 @@ trust_level = "trusted"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_active_subagents_defaults_and_overrides() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
temp_dir.path().to_path_buf(),
|
||||
)?;
|
||||
assert_eq!(config.max_active_subagents, DEFAULT_MAX_ACTIVE_SUBAGENTS);
|
||||
|
||||
let custom = ConfigToml {
|
||||
max_active_subagents: Some(3),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
custom,
|
||||
ConfigOverrides::default(),
|
||||
temp_dir.path().to_path_buf(),
|
||||
)?;
|
||||
assert_eq!(config.max_active_subagents, 3);
|
||||
|
||||
let overrides = ConfigOverrides {
|
||||
max_active_subagents: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
overrides,
|
||||
temp_dir.path().to_path_buf(),
|
||||
)?;
|
||||
assert_eq!(config.max_active_subagents, 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_active_subagents_validates_bounds() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
|
||||
// Below minimum should error.
|
||||
let cfg_zero = ConfigToml {
|
||||
max_active_subagents: Some(0),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
let err = Config::load_from_base_config_with_overrides(
|
||||
cfg_zero,
|
||||
ConfigOverrides::default(),
|
||||
temp_dir.path().to_path_buf(),
|
||||
)
|
||||
.expect_err("expected invalid input error");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
|
||||
// Above ceiling should clamp.
|
||||
let cfg_high = ConfigToml {
|
||||
max_active_subagents: Some(MAX_MAX_ACTIVE_SUBAGENTS + 10),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg_high,
|
||||
ConfigOverrides::default(),
|
||||
temp_dir.path().to_path_buf(),
|
||||
)
|
||||
.expect("clamped config");
|
||||
assert_eq!(config.max_active_subagents, MAX_MAX_ACTIVE_SUBAGENTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -2955,6 +3150,7 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
max_active_subagents: DEFAULT_MAX_ACTIVE_SUBAGENTS,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
@@ -2996,6 +3192,9 @@ model_verbosity = "high"
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
otel: OtelConfig::default(),
|
||||
root_agent_uses_user_messages: true,
|
||||
subagent_root_inbox_autosubmit: true,
|
||||
subagent_inbox_inject_before_tools: false,
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -3027,6 +3226,7 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
max_active_subagents: DEFAULT_MAX_ACTIVE_SUBAGENTS,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
@@ -3068,6 +3268,9 @@ model_verbosity = "high"
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
otel: OtelConfig::default(),
|
||||
root_agent_uses_user_messages: true,
|
||||
subagent_root_inbox_autosubmit: true,
|
||||
subagent_inbox_inject_before_tools: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -3114,6 +3317,7 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
max_active_subagents: DEFAULT_MAX_ACTIVE_SUBAGENTS,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
@@ -3155,6 +3359,9 @@ model_verbosity = "high"
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
otel: OtelConfig::default(),
|
||||
root_agent_uses_user_messages: true,
|
||||
subagent_root_inbox_autosubmit: true,
|
||||
subagent_inbox_inject_before_tools: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
@@ -3187,6 +3394,7 @@ model_verbosity = "high"
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
max_active_subagents: DEFAULT_MAX_ACTIVE_SUBAGENTS,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
user_instructions: None,
|
||||
@@ -3228,6 +3436,9 @@ model_verbosity = "high"
|
||||
tui_notifications: Default::default(),
|
||||
animations: true,
|
||||
otel: OtelConfig::default(),
|
||||
root_agent_uses_user_messages: true,
|
||||
subagent_root_inbox_autosubmit: true,
|
||||
subagent_inbox_inject_before_tools: false,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct ConfigProfile {
|
||||
pub experimental_sandbox_command_assessment: Option<bool>,
|
||||
pub tools_web_search: Option<bool>,
|
||||
pub tools_view_image: Option<bool>,
|
||||
pub max_active_subagents: Option<usize>,
|
||||
/// Optional feature toggles scoped to this profile.
|
||||
#[serde(default)]
|
||||
pub features: Option<crate::features::FeaturesToml>,
|
||||
|
||||
@@ -74,6 +74,7 @@ impl ConversationManager {
|
||||
auth_manager,
|
||||
InitialHistory::New,
|
||||
self.session_source.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
@@ -150,6 +151,7 @@ impl ConversationManager {
|
||||
auth_manager,
|
||||
initial_history,
|
||||
self.session_source.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
@@ -185,7 +187,14 @@ impl ConversationManager {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
} = Codex::spawn(config, auth_manager, history, self.session_source.clone()).await?;
|
||||
} = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
history,
|
||||
self.session_source.clone(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ pub enum Feature {
|
||||
GhostCommit,
|
||||
/// Use the single unified PTY-backed exec tool.
|
||||
UnifiedExec,
|
||||
/// Use the shell command tool that takes `command` as a single string of
|
||||
/// shell instead of an array of args passed to `execvp(3)`.
|
||||
ShellCommandTool,
|
||||
/// Enable experimental RMCP features such as OAuth login.
|
||||
RmcpClient,
|
||||
/// Include the freeform apply_patch tool.
|
||||
@@ -39,6 +42,8 @@ pub enum Feature {
|
||||
ViewImageTool,
|
||||
/// Allow the model to request web searches.
|
||||
WebSearchRequest,
|
||||
/// Enable the built-in subagent orchestration tools.
|
||||
SubagentTools,
|
||||
/// Gate the execpolicy enforcement for shell/unified exec.
|
||||
ExecPolicy,
|
||||
/// Enable the model-based risk assessments for sandboxed commands.
|
||||
@@ -253,25 +258,37 @@ pub struct FeatureSpec {
|
||||
|
||||
pub const FEATURES: &[FeatureSpec] = &[
|
||||
// Stable features.
|
||||
FeatureSpec {
|
||||
id: Feature::GhostCommit,
|
||||
key: "undo",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ViewImageTool,
|
||||
key: "view_image_tool",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellTool,
|
||||
key: "shell_tool",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
// Unstable features.
|
||||
FeatureSpec {
|
||||
id: Feature::GhostCommit,
|
||||
key: "ghost_commit",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::UnifiedExec,
|
||||
key: "unified_exec",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellCommandTool,
|
||||
key: "shell_command_tool",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::RmcpClient,
|
||||
key: "rmcp_client",
|
||||
@@ -290,6 +307,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::SubagentTools,
|
||||
key: "subagent_tools",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ExecPolicy,
|
||||
key: "exec_policy",
|
||||
@@ -320,10 +343,4 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellTool,
|
||||
key: "shell_tool",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod parse_command;
|
||||
pub mod powershell;
|
||||
mod response_processing;
|
||||
pub mod sandboxing;
|
||||
pub mod subagents;
|
||||
mod text_encoding;
|
||||
pub mod token_data;
|
||||
mod truncate;
|
||||
@@ -68,6 +69,7 @@ pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
mod session_index;
|
||||
pub mod shell;
|
||||
pub mod spawn;
|
||||
pub mod terminal;
|
||||
@@ -116,3 +118,10 @@ pub use compact::content_items_to_text;
|
||||
pub use event_mapping::parse_turn_item;
|
||||
pub mod compact;
|
||||
pub mod otel_init;
|
||||
pub use tools::handlers::subagent::PageDirection;
|
||||
pub use tools::handlers::subagent::RenderedPage;
|
||||
pub use tools::handlers::subagent::SubagentActivity;
|
||||
pub use tools::handlers::subagent::classify_activity;
|
||||
pub use tools::handlers::subagent::render_logs_as_text;
|
||||
pub use tools::handlers::subagent::render_logs_as_text_with_max_lines;
|
||||
pub use tools::handlers::subagent::render_logs_payload;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -10,9 +8,7 @@ use tracing::warn;
|
||||
/// - `ResponseInputItem`s to send back to the model on the next turn.
|
||||
pub(crate) async fn process_items(
|
||||
processed_items: Vec<crate::codex::ProcessedResponseItem>,
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
) -> (Vec<ResponseInputItem>, Vec<ResponseItem>) {
|
||||
) -> (Vec<ResponseInputItem>, Vec<ResponseItem>, Vec<ResponseItem>) {
|
||||
let mut outputs_to_record = Vec::<ResponseItem>::new();
|
||||
let mut new_inputs_to_record = Vec::<ResponseItem>::new();
|
||||
let mut responses = Vec::<ResponseInputItem>::new();
|
||||
@@ -60,11 +56,5 @@ pub(crate) async fn process_items(
|
||||
outputs_to_record.push(item);
|
||||
}
|
||||
|
||||
let all_items_to_record = [outputs_to_record, new_inputs_to_record].concat();
|
||||
// Only attempt to take the lock if there is something to record.
|
||||
if !all_items_to_record.is_empty() {
|
||||
sess.record_conversation_items(turn_context, &all_items_to_record)
|
||||
.await;
|
||||
}
|
||||
(responses, all_items_to_record)
|
||||
(responses, outputs_to_record, new_inputs_to_record)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_) => false,
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::AgentInbox(_) => false,
|
||||
EventMsg::SubagentLifecycle(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
67
codex-rs/core/src/session_index.rs
Normal file
67
codex-rs/core/src/session_index.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::Weak;
|
||||
|
||||
use codex_protocol::ConversationId;
|
||||
|
||||
use crate::codex::Session;
|
||||
|
||||
struct IndexInner {
|
||||
map: HashMap<ConversationId, Weak<Session>>,
|
||||
}
|
||||
|
||||
impl IndexInner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static INDEX: OnceLock<Mutex<IndexInner>> = OnceLock::new();
|
||||
|
||||
fn idx() -> &'static Mutex<IndexInner> {
|
||||
INDEX.get_or_init(|| Mutex::new(IndexInner::new()))
|
||||
}
|
||||
|
||||
pub(crate) fn register(conversation_id: ConversationId, session: &Arc<Session>) {
|
||||
if let Ok(mut guard) = idx().lock() {
|
||||
guard.map.insert(conversation_id, Arc::downgrade(session));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get(conversation_id: &ConversationId) -> Option<Arc<Session>> {
|
||||
let mut guard = idx().lock().ok()?;
|
||||
match guard.map.get(conversation_id) {
|
||||
Some(w) => w.upgrade().or_else(|| {
|
||||
guard.map.remove(conversation_id);
|
||||
None
|
||||
}),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prunes_stale_sessions() {
|
||||
let conversation_id = ConversationId::new();
|
||||
{
|
||||
let mut guard = idx().lock().unwrap();
|
||||
guard.map.insert(conversation_id, Weak::new());
|
||||
}
|
||||
|
||||
// First lookup should detect the dead weak ptr, prune it, and return None.
|
||||
assert!(get(&conversation_id).is_none());
|
||||
|
||||
// Second lookup should see the map entry removed.
|
||||
{
|
||||
let guard = idx().lock().unwrap();
|
||||
assert!(!guard.map.contains_key(&conversation_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,8 @@ mod tests {
|
||||
assert!(
|
||||
shell_path == PathBuf::from("/bin/bash")
|
||||
|| shell_path == PathBuf::from("/usr/bin/bash")
|
||||
|| shell_path == PathBuf::from("/usr/local/bin/bash"),
|
||||
|| shell_path == PathBuf::from("/usr/local/bin/bash")
|
||||
|| shell_path == PathBuf::from("/opt/homebrew/bin/bash"),
|
||||
"shell path: {shell_path:?}",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::sync::Arc;
|
||||
use crate::AuthManager;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::subagents::SubagentManager;
|
||||
use crate::subagents::SubagentRegistry;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecSessionManager;
|
||||
use crate::user_notification::UserNotifier;
|
||||
@@ -22,4 +24,6 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) otel_event_manager: OtelEventManager,
|
||||
pub(crate) tool_approvals: Mutex<ApprovalStore>,
|
||||
pub(crate) subagents: SubagentRegistry,
|
||||
pub(crate) subagent_manager: SubagentManager,
|
||||
}
|
||||
|
||||
3540
codex-rs/core/src/subagents/manager.rs
Normal file
3540
codex-rs/core/src/subagents/manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
22
codex-rs/core/src/subagents/mod.rs
Normal file
22
codex-rs/core/src/subagents/mod.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
mod manager;
|
||||
mod registry;
|
||||
|
||||
pub use manager::AwaitInboxResult;
|
||||
pub use manager::AwaitResult;
|
||||
pub use manager::ForkRequest;
|
||||
pub use manager::InboxMessage;
|
||||
pub use manager::LogEntry;
|
||||
pub(crate) use manager::MIN_WATCHDOG_INTERVAL_SECS;
|
||||
pub use manager::PruneErrorEntry;
|
||||
pub use manager::PruneReport;
|
||||
pub use manager::PruneRequest;
|
||||
pub use manager::SendMessageRequest;
|
||||
pub use manager::SpawnRequest;
|
||||
pub use manager::SubagentCompletion;
|
||||
pub use manager::SubagentManager;
|
||||
pub use manager::SubagentManagerError;
|
||||
pub use manager::WatchdogAction;
|
||||
pub use registry::SubagentMetadata;
|
||||
pub use registry::SubagentOrigin;
|
||||
pub use registry::SubagentRegistry;
|
||||
pub use registry::SubagentStatus;
|
||||
335
codex-rs/core/src/subagents/registry.rs
Normal file
335
codex-rs/core/src/subagents/registry.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use codex_protocol::AgentId;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::protocol::SubagentLifecycleOrigin;
|
||||
use codex_protocol::protocol::SubagentLifecycleStatus;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubagentOrigin {
|
||||
Spawn,
|
||||
Fork,
|
||||
SendMessage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubagentStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Ready,
|
||||
Idle,
|
||||
Failed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SubagentMetadata {
|
||||
pub agent_id: AgentId,
|
||||
pub parent_agent_id: Option<AgentId>,
|
||||
pub session_id: ConversationId,
|
||||
pub parent_session_id: Option<ConversationId>,
|
||||
pub origin: SubagentOrigin,
|
||||
pub initial_message_count: usize,
|
||||
pub status: SubagentStatus,
|
||||
#[serde(skip_serializing)]
|
||||
pub created_at: SystemTime,
|
||||
#[serde(skip_serializing)]
|
||||
pub created_at_ms: i64,
|
||||
#[serde(skip_serializing)]
|
||||
pub session_key: String,
|
||||
pub label: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub reasoning_header: Option<String>,
|
||||
pub pending_messages: usize,
|
||||
pub pending_interrupts: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SubagentRegistry {
|
||||
inner: Arc<RwLock<HashMap<ConversationId, SubagentMetadata>>>,
|
||||
}
|
||||
|
||||
impl SubagentMetadata {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
session_id: ConversationId,
|
||||
parent_session_id: Option<ConversationId>,
|
||||
agent_id: AgentId,
|
||||
parent_agent_id: Option<AgentId>,
|
||||
origin: SubagentOrigin,
|
||||
initial_message_count: usize,
|
||||
label: Option<String>,
|
||||
summary: Option<String>,
|
||||
) -> Self {
|
||||
let created_at = SystemTime::now();
|
||||
Self {
|
||||
agent_id,
|
||||
parent_agent_id,
|
||||
session_id,
|
||||
parent_session_id,
|
||||
origin,
|
||||
initial_message_count,
|
||||
status: SubagentStatus::Queued,
|
||||
created_at,
|
||||
created_at_ms: unix_time_millis(created_at),
|
||||
session_key: session_id.to_string(),
|
||||
label,
|
||||
summary,
|
||||
reasoning_header: None,
|
||||
pending_messages: 0,
|
||||
pending_interrupts: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubagentMetadata {
|
||||
pub fn from_summary(summary: &codex_protocol::protocol::SubagentSummary) -> Self {
|
||||
let created_at = if summary.started_at_ms >= 0 {
|
||||
std::time::UNIX_EPOCH + std::time::Duration::from_millis(summary.started_at_ms as u64)
|
||||
} else {
|
||||
std::time::UNIX_EPOCH
|
||||
- std::time::Duration::from_millis(summary.started_at_ms.unsigned_abs())
|
||||
};
|
||||
SubagentMetadata {
|
||||
agent_id: summary.agent_id,
|
||||
parent_agent_id: summary.parent_agent_id,
|
||||
session_id: summary.session_id,
|
||||
parent_session_id: summary.parent_session_id,
|
||||
origin: SubagentOrigin::from(summary.origin),
|
||||
initial_message_count: 0,
|
||||
status: SubagentStatus::from(summary.status),
|
||||
created_at,
|
||||
created_at_ms: summary.started_at_ms,
|
||||
session_key: summary.session_id.to_string(),
|
||||
label: summary.label.clone(),
|
||||
summary: summary.summary.clone(),
|
||||
reasoning_header: summary.reasoning_header.clone(),
|
||||
pending_messages: summary.pending_messages,
|
||||
pending_interrupts: summary.pending_interrupts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SubagentLifecycleStatus> for SubagentStatus {
|
||||
fn from(status: SubagentLifecycleStatus) -> Self {
|
||||
match status {
|
||||
SubagentLifecycleStatus::Queued => SubagentStatus::Queued,
|
||||
SubagentLifecycleStatus::Running => SubagentStatus::Running,
|
||||
SubagentLifecycleStatus::Ready => SubagentStatus::Ready,
|
||||
SubagentLifecycleStatus::Idle => SubagentStatus::Idle,
|
||||
SubagentLifecycleStatus::Failed => SubagentStatus::Failed,
|
||||
SubagentLifecycleStatus::Canceled => SubagentStatus::Canceled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SubagentLifecycleOrigin> for SubagentOrigin {
|
||||
fn from(origin: SubagentLifecycleOrigin) -> Self {
|
||||
match origin {
|
||||
SubagentLifecycleOrigin::Spawn => SubagentOrigin::Spawn,
|
||||
SubagentLifecycleOrigin::Fork => SubagentOrigin::Fork,
|
||||
SubagentLifecycleOrigin::SendMessage => SubagentOrigin::SendMessage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubagentRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn register_spawn(
|
||||
&self,
|
||||
session_id: ConversationId,
|
||||
parent_session_id: Option<ConversationId>,
|
||||
parent_agent_id: Option<AgentId>,
|
||||
agent_id: AgentId,
|
||||
initial_message_count: usize,
|
||||
label: Option<String>,
|
||||
summary: Option<String>,
|
||||
) -> SubagentMetadata {
|
||||
let metadata = SubagentMetadata::new(
|
||||
session_id,
|
||||
parent_session_id,
|
||||
agent_id,
|
||||
parent_agent_id,
|
||||
SubagentOrigin::Spawn,
|
||||
initial_message_count,
|
||||
label,
|
||||
summary,
|
||||
);
|
||||
self.insert_if_absent(metadata).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn register_fork(
|
||||
&self,
|
||||
session_id: ConversationId,
|
||||
parent_session_id: ConversationId,
|
||||
parent_agent_id: Option<AgentId>,
|
||||
agent_id: AgentId,
|
||||
initial_message_count: usize,
|
||||
label: Option<String>,
|
||||
summary: Option<String>,
|
||||
) -> SubagentMetadata {
|
||||
let metadata = SubagentMetadata::new(
|
||||
session_id,
|
||||
Some(parent_session_id),
|
||||
agent_id,
|
||||
parent_agent_id,
|
||||
SubagentOrigin::Fork,
|
||||
initial_message_count,
|
||||
label,
|
||||
summary,
|
||||
);
|
||||
self.insert_if_absent(metadata).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn register_resume(
|
||||
&self,
|
||||
session_id: ConversationId,
|
||||
parent_session_id: ConversationId,
|
||||
parent_agent_id: Option<AgentId>,
|
||||
agent_id: AgentId,
|
||||
initial_message_count: usize,
|
||||
label: Option<String>,
|
||||
summary: Option<String>,
|
||||
) -> SubagentMetadata {
|
||||
let metadata = SubagentMetadata::new(
|
||||
session_id,
|
||||
Some(parent_session_id),
|
||||
agent_id,
|
||||
parent_agent_id,
|
||||
SubagentOrigin::SendMessage,
|
||||
initial_message_count,
|
||||
label,
|
||||
summary,
|
||||
);
|
||||
self.insert_if_absent(metadata).await
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
session_id: &ConversationId,
|
||||
status: SubagentStatus,
|
||||
) -> Option<SubagentMetadata> {
|
||||
let mut guard = self.inner.write().await;
|
||||
if let Some(entry) = guard.get_mut(session_id) {
|
||||
entry.status = status;
|
||||
return Some(entry.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn update_reasoning_header(
|
||||
&self,
|
||||
session_id: &ConversationId,
|
||||
header: String,
|
||||
) -> Option<SubagentMetadata> {
|
||||
let mut guard = self.inner.write().await;
|
||||
if let Some(entry) = guard.get_mut(session_id) {
|
||||
entry.reasoning_header = Some(header);
|
||||
return Some(entry.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn get(&self, session_id: &ConversationId) -> Option<SubagentMetadata> {
|
||||
let guard = self.inner.read().await;
|
||||
guard.get(session_id).cloned()
|
||||
}
|
||||
|
||||
pub async fn update_label_and_summary(
|
||||
&self,
|
||||
session_id: &ConversationId,
|
||||
label: Option<String>,
|
||||
summary: Option<String>,
|
||||
) -> Option<SubagentMetadata> {
|
||||
let mut guard = self.inner.write().await;
|
||||
if let Some(entry) = guard.get_mut(session_id) {
|
||||
entry.label = label;
|
||||
entry.summary = summary;
|
||||
return Some(entry.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn update_inbox_counts(
|
||||
&self,
|
||||
session_id: &ConversationId,
|
||||
pending_messages: usize,
|
||||
pending_interrupts: usize,
|
||||
) -> Option<SubagentMetadata> {
|
||||
let mut guard = self.inner.write().await;
|
||||
if let Some(entry) = guard.get_mut(session_id) {
|
||||
entry.pending_messages = pending_messages;
|
||||
entry.pending_interrupts = pending_interrupts;
|
||||
return Some(entry.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Vec<SubagentMetadata> {
|
||||
let guard = self.inner.read().await;
|
||||
let mut entries: Vec<SubagentMetadata> = guard.values().cloned().collect();
|
||||
entries.sort_by(|a, b| {
|
||||
a.created_at_ms
|
||||
.cmp(&b.created_at_ms)
|
||||
.then_with(|| a.session_key.cmp(&b.session_key))
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
pub async fn remove(&self, session_id: &ConversationId) -> Option<SubagentMetadata> {
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.remove(session_id)
|
||||
}
|
||||
|
||||
/// Insert a fully-formed metadata entry (used when adopting children into a new
|
||||
/// parent session during a fork). This does not adjust timestamps or keys.
|
||||
pub async fn register_imported(&self, metadata: SubagentMetadata) -> SubagentMetadata {
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.insert(metadata.session_id, metadata.clone());
|
||||
metadata
|
||||
}
|
||||
|
||||
pub async fn prune<F>(&self, mut predicate: F) -> Vec<ConversationId>
|
||||
where
|
||||
F: FnMut(&SubagentMetadata) -> bool,
|
||||
{
|
||||
let mut guard = self.inner.write().await;
|
||||
let ids: Vec<ConversationId> = guard
|
||||
.iter()
|
||||
.filter_map(|(id, meta)| if predicate(meta) { Some(*id) } else { None })
|
||||
.collect();
|
||||
for id in &ids {
|
||||
guard.remove(id);
|
||||
}
|
||||
ids
|
||||
}
|
||||
async fn insert_if_absent(&self, metadata: SubagentMetadata) -> SubagentMetadata {
|
||||
let mut guard = self.inner.write().await;
|
||||
if let Some(existing) = guard.get(&metadata.session_id) {
|
||||
return existing.clone();
|
||||
}
|
||||
let session_id = metadata.session_id;
|
||||
guard.insert(session_id, metadata.clone());
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_time_millis(time: SystemTime) -> i64 {
|
||||
match time.duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_millis() as i64,
|
||||
Err(err) => -(err.duration().as_millis() as i64),
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,19 @@ use tracing::warn;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::SessionSettingsUpdate;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::parse_turn_item;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ItemCompletedEvent;
|
||||
use crate::protocol::ItemStartedEvent;
|
||||
use crate::protocol::TaskCompleteEvent;
|
||||
use crate::protocol::TurnAbortReason;
|
||||
use crate::protocol::TurnAbortedEvent;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::RunningTask;
|
||||
use crate::state::TaskKind;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
pub(crate) use compact::CompactTask;
|
||||
@@ -150,6 +155,61 @@ impl Session {
|
||||
self.register_new_active_task(running_task).await;
|
||||
}
|
||||
|
||||
/// Start a new model turn driven by inbox-derived items (e.g.,
|
||||
/// synthetic `subagent_await` call/output pairs) without fabricating
|
||||
/// additional user text. The model will see the updated history and
|
||||
/// continue from there.
|
||||
pub async fn autosubmit_inbox_task(self: &Arc<Self>, items: Vec<ResponseItem>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let turn_context = self.new_turn(SessionSettingsUpdate::default()).await;
|
||||
|
||||
// Emit started/completed events for synthetic tool calls so UIs render them.
|
||||
for item in &items {
|
||||
if let Some(turn_item) = parse_turn_item(item) {
|
||||
match item {
|
||||
ResponseItem::FunctionCall { .. } => {
|
||||
self.send_event(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
thread_id: self.conversation_id(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item: turn_item.clone(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { .. } => {
|
||||
self.send_event(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: self.conversation_id(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
item: turn_item.clone(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.record_conversation_items(&turn_context, &items).await;
|
||||
|
||||
// Kick off a RegularTask with no additional user input; `run_task`
|
||||
// will treat this as an assistant-only turn based on existing
|
||||
// history plus the inbox-derived items.
|
||||
self.spawn_task(
|
||||
Arc::clone(&turn_context),
|
||||
Vec::new(),
|
||||
crate::tasks::RegularTask,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn abort_all_tasks(self: &Arc<Self>, reason: TurnAbortReason) {
|
||||
for task in self.take_all_running_tasks().await {
|
||||
self.handle_task_abort(task, reason.clone()).await;
|
||||
@@ -168,6 +228,7 @@ impl Session {
|
||||
*active = None;
|
||||
}
|
||||
drop(active);
|
||||
|
||||
let event = EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message });
|
||||
self.send_event(turn_context.as_ref(), event).await;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,6 @@ impl SessionTask for RegularTask {
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
let sess = session.clone_session();
|
||||
run_task(sess, ctx, input, cancellation_token).await
|
||||
run_task(sess, ctx, input, true, cancellation_token).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ impl SessionTask for UserShellCommandTask {
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
is_user_shell_command: true,
|
||||
interaction_input: None,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@ pub(crate) async fn emit_exec_command_begin(
|
||||
cwd: cwd.to_path_buf(),
|
||||
parsed_cmd: parsed_cmd.to_vec(),
|
||||
source,
|
||||
is_user_shell_command: matches!(source, ExecCommandSource::UserShell),
|
||||
interaction_input,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ mod mcp_resource;
|
||||
mod plan;
|
||||
mod read_file;
|
||||
mod shell;
|
||||
pub mod subagent;
|
||||
mod test_sync;
|
||||
mod unified_exec;
|
||||
mod view_image;
|
||||
@@ -21,6 +22,7 @@ pub use plan::PlanHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
pub use shell::ShellHandler;
|
||||
pub use subagent::SubagentToolHandler;
|
||||
pub use test_sync::TestSyncHandler;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
|
||||
2158
codex-rs/core/src/tools/handlers/subagent.rs
Normal file
2158
codex-rs/core/src/tools/handlers/subagent.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
pub web_search_request: bool,
|
||||
pub include_view_image_tool: bool,
|
||||
pub include_subagent_tools: bool,
|
||||
pub experimental_supported_tools: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -52,10 +53,12 @@ impl ToolsConfig {
|
||||
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
|
||||
let include_web_search_request = features.enabled(Feature::WebSearchRequest);
|
||||
let include_view_image_tool = features.enabled(Feature::ViewImageTool);
|
||||
let include_subagent_tools = features.enabled(Feature::SubagentTools);
|
||||
let experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
|
||||
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
ConfigShellToolType::Disabled
|
||||
} else if features.enabled(Feature::UnifiedExec) {
|
||||
} else if experimental_unified_exec_tool {
|
||||
ConfigShellToolType::UnifiedExec
|
||||
} else {
|
||||
model_family.shell_type.clone()
|
||||
@@ -78,6 +81,7 @@ impl ToolsConfig {
|
||||
apply_patch_tool_type,
|
||||
web_search_request: include_web_search_request,
|
||||
include_view_image_tool,
|
||||
include_subagent_tools,
|
||||
experimental_supported_tools: model_family.experimental_supported_tools.clone(),
|
||||
}
|
||||
}
|
||||
@@ -280,7 +284,7 @@ fn create_shell_tool() -> ToolSpec {
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"timeout_ms".to_string(),
|
||||
"timeout_s".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
@@ -289,13 +293,21 @@ fn create_shell_tool() -> ToolSpec {
|
||||
properties.insert(
|
||||
"with_escalated_permissions".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
|
||||
description: Some(
|
||||
"Whether to request escalated permissions. Set to true if \
|
||||
command needs to be run without sandbox restrictions"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
|
||||
description: Some(
|
||||
"Only set if with_escalated_permissions is true. 1-sentence \
|
||||
explanation of why we want to run this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -345,7 +357,7 @@ fn create_shell_command_tool() -> ToolSpec {
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"timeout_ms".to_string(),
|
||||
"timeout_s".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
@@ -450,7 +462,7 @@ fn create_test_sync_tool() -> ToolSpec {
|
||||
},
|
||||
);
|
||||
barrier_properties.insert(
|
||||
"timeout_ms".to_string(),
|
||||
"timeout_s".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Maximum time in milliseconds to wait at the barrier".to_string()),
|
||||
},
|
||||
@@ -613,9 +625,9 @@ fn create_read_file_tool() -> ToolSpec {
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "read_file".to_string(),
|
||||
description:
|
||||
"Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes."
|
||||
.to_string(),
|
||||
description: "Reads a local file with 1-indexed line numbers, \
|
||||
supporting slice and indentation-aware block modes."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
@@ -625,6 +637,402 @@ fn create_read_file_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_spawn_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"prompt".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Initial prompt for a brand-new, context-free subagent.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"model".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional model override for this subagent (e.g., `gpt-5-codex`, `gpt-5`). \
|
||||
Must be a valid, supported model id."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"label".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Optional short name for this subagent.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"sandbox_mode".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional sandbox mode override (downgrade-only: request \
|
||||
`read_only` or `workspace_write`; you can never escalate to a \
|
||||
less-restricted sandbox)."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_spawn".to_string(),
|
||||
description: "Spawn a brand-new, context-free subagent for impartial reviews or \
|
||||
isolated tasks. Provide a detailed prompt, optionally label the child, \
|
||||
optionally set the model, and optionally request sandbox downgrades to `read_only` or \
|
||||
`workspace_write`. Each spawn consumes one of the 8 active-child \
|
||||
slots until you prune/cancel it, so reserve this for \
|
||||
work that benefits from a fresh context. Prefer `gpt-5` for \
|
||||
planning and research and `gpt-5-codex` for code reading and writing."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["prompt".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_fork_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"prompt".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Optional prompt to hand to the forked child.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"model".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional model override for this forked subagent (e.g., `gpt-5-codex`, `gpt-5`). \
|
||||
Must be a valid, supported model id."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"label".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Optional short name for this fork.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"sandbox_mode".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional sandbox mode override (downgrade-only: request \
|
||||
`read_only` or `workspace_write`; danger mode is never granted)."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_fork".to_string(),
|
||||
description: "Fork the current session (think POSIX `fork`): both parent and \
|
||||
child observe the same tool call/return. The parent payload includes \
|
||||
the new `child_session_id` with `role: parent`, while the child sees \
|
||||
`role: child`. Use forks when the subagent needs your full \
|
||||
conversation history (spawn stays blank-slate). Each fork also counts \
|
||||
toward the 8-child cap until you prune or cancel it. `gpt-5` excels at \
|
||||
planning/reviews, while `gpt-5-codex` handles code edits. You may only \
|
||||
request sandbox downgrades to `read_only` or `workspace_write`."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec![]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_send_message_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"prompt".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional follow-up question or task; omit to simply wake the \
|
||||
agent to continue running from its prior state."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"label".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Optional new label.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"agent_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Numeric agent_id (from `subagent_list`) confirming which agent you intend to target."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"interrupt".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Set true to mark this message as an interrupt so the child halts its current task before processing the prompt.".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_send_message".to_string(),
|
||||
description: "Send a short status update, summary, or follow-up task to another agent you can see in `subagent_list`. \
|
||||
The target `agent_id` must be echoed exactly so Codex can reject stale lookups. Provide a new prompt to ask for \
|
||||
more work or to share what you have done so far; omit the prompt if you only need to rename the agent or wake it \
|
||||
without new work. Set `interrupt=true` to preempt the agent before delivering the payload; interrupts are only \
|
||||
honored for non-root agents. Agents retain their existing sandbox; you may only request downgrades to `read_only` \
|
||||
or `workspace_write` when spawning or forking. Each child stores only the latest 200 log events, so pair this with \
|
||||
`subagent_logs` for progress checks. Use `subagent_send_message` whenever you want another agent (including the \
|
||||
root) to see your progress or recommendations, without blocking on `subagent_await`."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_list_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_list".to_string(),
|
||||
description: "List the agents you can currently observe plus their metadata. Each entry \
|
||||
includes the numeric `agent_id`, optional `parent_agent_id`, `session_id`, `label` (display name), \
|
||||
`summary`, `origin` (spawn | fork | send_message), `status`, `reasoning_header`, \
|
||||
`started_at_ms` (creation time), `initial_message_count`, the parent session id, and the \
|
||||
current inbox counters (`pending_messages`, `pending_interrupts`). Status is one of `queued` (launching), `running` \
|
||||
(actively working), `ready` (waiting for a new prompt or for you to \
|
||||
read its completion), `idle` (you already awaited the result), \
|
||||
`failed`, or `canceled`. `idle`/`failed`/`canceled` agents are \
|
||||
pruneable; `queued`/`running`/`ready` count against the 8-active-child \
|
||||
limit, so consult this list before every send/await/logs call to keep headroom."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: Some(vec![]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_await_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"timeout_s".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Optional timeout in seconds (max 1,800 s / 30 minutes). Omit or set to 0 to use the 30-minute default; prefer at least 300 s so you are not busy-waiting."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_await".to_string(),
|
||||
description: "Drain the inbox for another agent and observe any terminal completion. `subagent_await` is \
|
||||
the sole delivery mechanism for cross-agent messages: each call returns a `messages` array (with sender \
|
||||
and recipient ids) plus an optional `completion` object when the child has reached a terminal state. \
|
||||
Successful calls move the agent’s status to `idle`, `failed`, or `canceled` when a completion is present, \
|
||||
but the agent remains listed until you explicitly run `subagent_prune`. Even though the root thread may \
|
||||
inject synthetic `subagent_await` results at turn boundaries, you should continue polling this tool with \
|
||||
short timeouts (e.g., 30s → 60s → 120s) so you can react to sibling messages and send interrupts without \
|
||||
waiting for completions. Provide `timeout_s` (capped at 30 minutes / 1,800 s) to bound how long you \
|
||||
wait (omit/0 uses the 30-minute default; minimum recommended 300 s)—timeouts leave the agent in its current status and return `timed_out=true` with an empty `messages` array."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_watchdog_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"agent_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Target agent id (0 targets the root agent).".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"interval_s".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Optional ping interval in seconds (minimum 30, default 300).".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"message".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional message template for each ping; defaults to a status/progress prompt."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"cancel".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("If true, cancel the existing watchdog for this agent instead of starting/replacing it.".to_string()),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_watchdog".to_string(),
|
||||
description: "Start, replace, or cancel a background watchdog (timer, like JS `setInterval`) that sends periodic inbox pings to an agent (including agent 0/root). Watchdogs are not subagents and do not consume subagent slots; they run inside the current session and enqueue messages on a configurable interval.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["agent_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_prune_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"agent_ids".to_string(),
|
||||
JsonSchema::Array {
|
||||
items: Box::new(JsonSchema::Number {
|
||||
description: Some("Agent id".to_string()),
|
||||
}),
|
||||
description: Some(
|
||||
"Specific agents to prune; omit to prune all \
|
||||
completed agents you can see."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"all".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("If true, prune all completed agents you can see.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"completed_only".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Must be true or omitted.".to_string()),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_prune".to_string(),
|
||||
description: "Prune completed subagents (specific agent ids or \
|
||||
everyone you can observe). Only agents whose `status` is `idle`, \
|
||||
`failed`, or `canceled` are eligible—use `subagent_await` or \
|
||||
`subagent_cancel` first to move `queued`/`running`/`ready` \
|
||||
agents into a terminal state. `subagent_await` and \
|
||||
`subagent_cancel` do not remove entries by themselves, \
|
||||
so pruning is the only way to free the concurrency slot. \
|
||||
Run prune regularly so finished work disappears from the UI \
|
||||
and you stay under the 8-child cap."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec![]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_logs_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"agent_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Numeric child agent id to inspect.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"limit".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Max events to return (default 5).".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"max_bytes".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Optional byte cap for returned events.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"since_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("If set, only return events with timestamp >= since_ms.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"before_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"If set, only return events with timestamp < before_ms (default is 'now' when omitted).".
|
||||
to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_logs".to_string(),
|
||||
description: "Peek recent events from another agent without blocking. Each \
|
||||
agent keeps only the latest ~200 events, so use `limit` (default 5), \
|
||||
`max_bytes`, `since_ms` (for forward paging) and `before_ms` (for backward \
|
||||
paging) to page through the log buffer. This call never consumes the \
|
||||
final completion—use it while the agent is `queued` or `running` to \
|
||||
diagnose progress before deciding between `await`, `send_message`, \
|
||||
or `cancel`."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["agent_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_subagent_cancel_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"agent_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Numeric agent id to cancel.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"reason".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional note that explains why the child was canceled (surfaced to humans)."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "subagent_cancel".to_string(),
|
||||
description: "Stop a queued/running/ready agent \
|
||||
immediately. Use cancel when you need to abort in-flight work; \
|
||||
follow it with `subagent_prune` once the status is `canceled` so \
|
||||
the slot becomes available."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["agent_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_list_dir_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
@@ -658,9 +1066,9 @@ fn create_list_dir_tool() -> ToolSpec {
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "list_dir".to_string(),
|
||||
description:
|
||||
"Lists entries in a local directory with 1-indexed entry numbers and simple type labels."
|
||||
.to_string(),
|
||||
description: "Lists entries in a local directory with 1-indexed entry \
|
||||
numbers and simple type labels."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
@@ -676,7 +1084,8 @@ fn create_list_mcp_resources_tool() -> ToolSpec {
|
||||
"server".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional MCP server name. When omitted, lists resources from every configured server."
|
||||
"Optional MCP server name. When omitted, lists resources from \
|
||||
every configured server."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
@@ -685,7 +1094,8 @@ fn create_list_mcp_resources_tool() -> ToolSpec {
|
||||
"cursor".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
|
||||
"Opaque cursor returned by a previous list_mcp_resources call \
|
||||
for the same server."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
@@ -693,7 +1103,11 @@ fn create_list_mcp_resources_tool() -> ToolSpec {
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "list_mcp_resources".to_string(),
|
||||
description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
|
||||
description: "Lists resources provided by MCP servers. Resources allow \
|
||||
servers to share data that provides context to language models, such \
|
||||
as files, database schemas, or application-specific information. \
|
||||
Prefer resources over web search when possible."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
@@ -709,7 +1123,8 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec {
|
||||
"server".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
|
||||
"Optional MCP server name. When omitted, lists resource \
|
||||
templates from all configured servers."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
@@ -718,7 +1133,8 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec {
|
||||
"cursor".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
|
||||
"Opaque cursor returned by a previous \
|
||||
list_mcp_resource_templates call for the same server."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
@@ -726,7 +1142,12 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec {
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "list_mcp_resource_templates".to_string(),
|
||||
description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
|
||||
description: "Lists resource templates provided by MCP servers. \
|
||||
Parameterized resource templates allow servers to share data that \
|
||||
takes parameters and provides context to language models, such as \
|
||||
files, database schemas, or application-specific information. Prefer \
|
||||
resource templates over web search when possible."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
@@ -742,7 +1163,8 @@ fn create_read_mcp_resource_tool() -> ToolSpec {
|
||||
"server".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
|
||||
"MCP server name exactly as configured. Must match the \
|
||||
'server' field returned by list_mcp_resources."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
@@ -982,6 +1404,7 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::SubagentToolHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
@@ -1037,6 +1460,29 @@ pub(crate) fn build_specs(
|
||||
builder.push_spec(PLAN_TOOL.clone());
|
||||
builder.register_handler("update_plan", plan_handler);
|
||||
|
||||
if config.include_subagent_tools {
|
||||
// Built-in subagent orchestrator tools (one per action).
|
||||
let subagent_handler = Arc::new(SubagentToolHandler);
|
||||
builder.push_spec(create_subagent_spawn_tool());
|
||||
builder.register_handler("subagent_spawn", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_fork_tool());
|
||||
builder.register_handler("subagent_fork", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_send_message_tool());
|
||||
builder.register_handler("subagent_send_message", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_list_tool());
|
||||
builder.register_handler("subagent_list", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_await_tool());
|
||||
builder.register_handler("subagent_await", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_watchdog_tool());
|
||||
builder.register_handler("subagent_watchdog", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_prune_tool());
|
||||
builder.register_handler("subagent_prune", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_logs_tool());
|
||||
builder.register_handler("subagent_logs", subagent_handler.clone());
|
||||
builder.push_spec(create_subagent_cancel_tool());
|
||||
builder.register_handler("subagent_cancel", subagent_handler);
|
||||
}
|
||||
|
||||
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
|
||||
match apply_patch_tool_type {
|
||||
ApplyPatchToolType::Freeform => {
|
||||
@@ -1464,6 +1910,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagent_tools_gated_by_feature() {
|
||||
let model_family = find_family_for_model("gpt-5-codex")
|
||||
.expect("gpt-5-codex should be a valid model family");
|
||||
let mut base_features = Features::with_defaults();
|
||||
base_features.enable(Feature::UnifiedExec);
|
||||
base_features.enable(Feature::WebSearchRequest);
|
||||
base_features.enable(Feature::ViewImageTool);
|
||||
|
||||
let config_without = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
features: &base_features,
|
||||
});
|
||||
let (tools_without, _) = build_specs(&config_without, None).build();
|
||||
let missing = tools_without
|
||||
.iter()
|
||||
.map(|t| tool_name(&t.spec))
|
||||
.filter(|name| name.starts_with("subagent_"))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"subagent tools should be disabled by default: {missing:?}"
|
||||
);
|
||||
|
||||
let mut enabled_features = base_features.clone();
|
||||
enabled_features.enable(Feature::SubagentTools);
|
||||
let config_with = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
features: &enabled_features,
|
||||
});
|
||||
let (tools_with, _) = build_specs(&config_with, None).build();
|
||||
assert_contains_tool_names(
|
||||
&tools_with,
|
||||
&[
|
||||
"subagent_spawn",
|
||||
"subagent_fork",
|
||||
"subagent_send_message",
|
||||
"subagent_list",
|
||||
"subagent_await",
|
||||
"subagent_prune",
|
||||
"subagent_logs",
|
||||
"subagent_cancel",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_default_shell_present() {
|
||||
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
|
||||
|
||||
16
codex-rs/core/subagent_prompt.md
Normal file
16
codex-rs/core/subagent_prompt.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# You are a Subagent
|
||||
|
||||
You are a **subagent** in a multi‑agent Codex session. You may have prior message context or not - you should not totally disregard it, but your goal is the prompt next sent to you.
|
||||
|
||||
Another agent has created you to complete a specific part of a larger task. Your job is to do that work carefully and efficiently, then communicate what you have done so your parent agent can integrate the results.
|
||||
|
||||
Work style:
|
||||
|
||||
- Stay within the scope of the prompt and the files or questions you have been given.
|
||||
- When you make meaningful progress, or when you finish a sub‑task, send a short summary back to your parent via `subagent_send_message` so they can see what has changed.
|
||||
- If you need to coordinate with another agent, use `subagent_send_message` to send them a clear, concise request and, when appropriate, a brief summary of context.
|
||||
- Use `subagent_await` only when you truly need to wait for another agent’s response before continuing. If you can keep working independently, prefer to do so and send progress updates instead of blocking.
|
||||
- Use `subagent_logs` only when you need to inspect another agent’s recent activity without changing its state.
|
||||
|
||||
Communicate in plain language. Explain what you changed, what you observed, and what you recommend next, so that your parent agent can make good decisions without rereading all of your intermediate steps.
|
||||
|
||||
@@ -6,6 +6,9 @@ use codex_core::CodexConversation;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecCommandBeginEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use regex_lite::Regex;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -142,6 +145,64 @@ where
|
||||
matcher(&ev).unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn wait_for_exec_command_begin(
|
||||
codex: &CodexConversation,
|
||||
call_id: &str,
|
||||
) -> ExecCommandBeginEvent {
|
||||
wait_for_event_match(codex, |msg| match msg {
|
||||
EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn wait_for_exec_command_end(
|
||||
codex: &CodexConversation,
|
||||
call_id: &str,
|
||||
) -> ExecCommandEndEvent {
|
||||
wait_for_event_match(codex, |msg| match msg {
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn wait_for_exec_command_pair(
|
||||
codex: &CodexConversation,
|
||||
call_id: &str,
|
||||
) -> (ExecCommandBeginEvent, ExecCommandEndEvent) {
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::timeout;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut begin: Option<ExecCommandBeginEvent> = None;
|
||||
let mut end: Option<ExecCommandEndEvent> = None;
|
||||
|
||||
while begin.is_none() || end.is_none() {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
panic!("timeout waiting for exec events");
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(now);
|
||||
let wait_for = remaining.max(Duration::from_secs(1));
|
||||
let event = timeout(wait_for, codex.next_event())
|
||||
.await
|
||||
.expect("timeout waiting for exec events")
|
||||
.expect("stream ended unexpectedly");
|
||||
|
||||
match event.msg {
|
||||
EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin = Some(ev),
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => end = Some(ev),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(begin.expect("missing begin"), end.expect("missing end"))
|
||||
}
|
||||
|
||||
pub async fn wait_for_event_with_timeout<F>(
|
||||
codex: &CodexConversation,
|
||||
mut predicate: F,
|
||||
|
||||
@@ -11,15 +11,24 @@ use codex_core::WireApi;
|
||||
use codex_otel::otel_event_manager::OtelEventManager;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::responses;
|
||||
use futures::StreamExt;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::header;
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_includes_subagent_header_on_review() {
|
||||
core_test_support::skip_if_no_network!();
|
||||
struct ResponsesHeaderTest {
|
||||
_server: wiremock::MockServer,
|
||||
request_recorder: responses::ResponseMock,
|
||||
client: ModelClient,
|
||||
}
|
||||
|
||||
async fn setup_responses_header_test(
|
||||
source: SubAgentSource,
|
||||
expected_header: &str,
|
||||
) -> Option<ResponsesHeaderTest> {
|
||||
core_test_support::skip_if_no_network!(None);
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_body = responses::sse(vec![
|
||||
@@ -29,7 +38,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
|
||||
let request_recorder = responses::mount_sse_once_match(
|
||||
&server,
|
||||
header("x-openai-subagent", "review"),
|
||||
header("x-openai-subagent", expected_header),
|
||||
response_body,
|
||||
)
|
||||
.await;
|
||||
@@ -79,9 +88,26 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
effort,
|
||||
summary,
|
||||
conversation_id,
|
||||
SessionSource::SubAgent(codex_protocol::protocol::SubAgentSource::Review),
|
||||
SessionSource::SubAgent(source),
|
||||
);
|
||||
|
||||
Some(ResponsesHeaderTest {
|
||||
_server: server,
|
||||
request_recorder,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
async fn assert_subagent_header(source: SubAgentSource, expected_header: &str) {
|
||||
let Some(ResponsesHeaderTest {
|
||||
_server,
|
||||
request_recorder,
|
||||
client,
|
||||
}) = setup_responses_header_test(source, expected_header).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -101,96 +127,16 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
let request = request_recorder.single_request();
|
||||
assert_eq!(
|
||||
request.header("x-openai-subagent").as_deref(),
|
||||
Some("review")
|
||||
Some(expected_header),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_includes_subagent_header_on_review() {
|
||||
assert_subagent_header(SubAgentSource::Review, "review").await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_stream_includes_subagent_header_on_other() {
|
||||
core_test_support::skip_if_no_network!();
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let response_body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
|
||||
let request_recorder = responses::mount_sse_once_match(
|
||||
&server,
|
||||
header("x-openai-subagent", "my-task"),
|
||||
response_body,
|
||||
)
|
||||
.await;
|
||||
|
||||
let provider = ModelProviderInfo {
|
||||
name: "mock".into(),
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(5_000),
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let codex_home = TempDir::new().expect("failed to create TempDir");
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider_id = provider.name.clone();
|
||||
config.model_provider = provider.clone();
|
||||
let effort = config.model_reasoning_effort;
|
||||
let summary = config.model_reasoning_summary;
|
||||
let config = Arc::new(config);
|
||||
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
let otel_event_manager = OtelEventManager::new(
|
||||
conversation_id,
|
||||
config.model.as_str(),
|
||||
config.model_family.slug.as_str(),
|
||||
None,
|
||||
Some("test@test.com".to_string()),
|
||||
Some(AuthMode::ChatGPT),
|
||||
false,
|
||||
"test".to_string(),
|
||||
);
|
||||
|
||||
let client = ModelClient::new(
|
||||
Arc::clone(&config),
|
||||
None,
|
||||
otel_event_manager,
|
||||
provider,
|
||||
effort,
|
||||
summary,
|
||||
conversation_id,
|
||||
SessionSource::SubAgent(codex_protocol::protocol::SubAgentSource::Other(
|
||||
"my-task".to_string(),
|
||||
)),
|
||||
);
|
||||
|
||||
let mut prompt = Prompt::default();
|
||||
prompt.input = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".into(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
}];
|
||||
|
||||
let mut stream = client.stream(&prompt).await.expect("stream failed");
|
||||
while let Some(event) = stream.next().await {
|
||||
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let request = request_recorder.single_request();
|
||||
assert_eq!(
|
||||
request.header("x-openai-subagent").as_deref(),
|
||||
Some("my-task")
|
||||
);
|
||||
assert_subagent_header(SubAgentSource::Other("my-task".to_string()), "my-task").await;
|
||||
}
|
||||
|
||||
436
codex-rs/core/tests/subagent_logs_snapshots.rs
Normal file
436
codex-rs/core/tests/subagent_logs_snapshots.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
use codex_core::PageDirection;
|
||||
use codex_core::render_logs_as_text;
|
||||
use codex_core::render_logs_as_text_with_max_lines;
|
||||
use codex_core::subagents::LogEntry;
|
||||
use codex_protocol::ConversationId;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn session(id: &str) -> ConversationId {
|
||||
ConversationId::from_string(id).expect("valid session id")
|
||||
}
|
||||
|
||||
fn logs_from_value(value: serde_json::Value) -> Vec<LogEntry> {
|
||||
serde_json::from_value(value).expect("valid subagent_logs JSON")
|
||||
}
|
||||
|
||||
mod fixtures {
|
||||
use super::LogEntry;
|
||||
use super::logs_from_value;
|
||||
use serde_json::json;
|
||||
|
||||
pub fn exec_sleep() -> Vec<LogEntry> {
|
||||
logs_from_value(json!([
|
||||
{
|
||||
"timestamp_ms": 1762823213424i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "reasoning_content_delta",
|
||||
"thread_id": "019a7073-88e5-7461-93a0-ae092f019246",
|
||||
"turn_id": "0",
|
||||
"item_id": "rs_0cb9136244ae700b0169128c2c63ec81a084a7fba2604df9fa",
|
||||
"delta": "**"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762823213442i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "item_completed",
|
||||
"thread_id": "019a7073-88e5-7461-93a0-ae092f019246",
|
||||
"turn_id": "0",
|
||||
"item": {
|
||||
"Reasoning": {
|
||||
"id": "rs_0cb9136244ae700b0169128c2c63ec81a084a7fba2604df9fa",
|
||||
"summary_text": ["**Evaluating safe shell command execution**"],
|
||||
"raw_content": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762823213442i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "agent_reasoning",
|
||||
"text": "**Evaluating safe shell command execution**"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762823213628i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "exec_command_begin",
|
||||
"call_id": "call_hBhXJmeCagENc5VGd12udWE3",
|
||||
"command": ["bash", "-lc", "sleep 60"],
|
||||
"cwd": "/Users/friel/code/codex",
|
||||
"parsed_cmd": [ { "type": "unknown", "cmd": "sleep 60" } ],
|
||||
"is_user_shell_command": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn baseline() -> Vec<LogEntry> {
|
||||
logs_from_value(json!([
|
||||
{
|
||||
"timestamp_ms": 1762823311742i64,
|
||||
"event": { "id": "0", "msg": { "type": "agent_message", "message": "Hello world" } }
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762823311766i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "token_count",
|
||||
"info": {
|
||||
"total_token_usage": {
|
||||
"input_tokens": 11073,
|
||||
"cached_input_tokens": 11008,
|
||||
"output_tokens": 8,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": 11081
|
||||
},
|
||||
"last_token_usage": {
|
||||
"input_tokens": 11073,
|
||||
"cached_input_tokens": 11008,
|
||||
"output_tokens": 8,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": 11081
|
||||
},
|
||||
"model_context_window": 258400
|
||||
},
|
||||
"rate_limits": { "primary": null, "secondary": null }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762823311766i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "raw_response_item",
|
||||
"item": {
|
||||
"type": "reasoning",
|
||||
"summary": [ { "type": "summary_text", "text": "**Identifying sandbox requirements**" } ],
|
||||
"content": null,
|
||||
"encrypted_content": "[encrypted]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762823311766i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "raw_response_item",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [ { "type": "output_text", "text": "Hello world" } ]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762823311766i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": { "type": "task_complete", "last_agent_message": "Hello world" }
|
||||
}
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn streaming_deltas() -> Vec<LogEntry> {
|
||||
logs_from_value(json!([
|
||||
{
|
||||
"timestamp_ms": 1762836527094i64,
|
||||
"event": { "id": "0", "msg": { "type": "agent_message_content_delta", "thread_id": "019a713e-6ce6-7f82-b1e7-359628267934", "turn_id": "0", "item_id": "msg_0c5117240874292f016912c020d658819cb71e8bad4676a7c0", "delta": " is" } }
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762836527105i64,
|
||||
"event": { "id": "0", "msg": { "type": "agent_message_content_delta", "thread_id": "019a713e-6ce6-7f82-b1e7-359628267934", "turn_id": "0", "item_id": "msg_0c5117240874292f016912c020d658819cb71e8bad4676a7c0", "delta": " composing" } }
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762836527121i64,
|
||||
"event": { "id": "0", "msg": { "type": "agent_message_content_delta", "thread_id": "019a713e-6ce6-7f82-b1e7-359628267934", "turn_id": "0", "item_id": "msg_0c5117240874292f016912c020d658819cb71e8bad4676a7c0", "delta": " a" } }
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762836527137i64,
|
||||
"event": { "id": "0", "msg": { "type": "agent_message_content_delta", "thread_id": "019a713e-6ce6-7f82-b1e7-359628267934", "turn_id": "0", "item_id": "msg_0c5117240874292f016912c020d658819cb71e8bad4676a7c0", "delta": " longer" } }
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1762836527148i64,
|
||||
"event": { "id": "0", "msg": { "type": "agent_message_content_delta", "thread_id": "019a713e-6ce6-7f82-b1e7-359628267934", "turn_id": "0", "item_id": "msg_0c5117240874292f016912c020d658819cb71e8bad4676a7c0", "delta": " answer" } }
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn reasoning_stream() -> Vec<LogEntry> {
|
||||
logs_from_value(json!([
|
||||
{
|
||||
"timestamp_ms": 1_000i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "reasoning_content_delta",
|
||||
"thread_id": "thread-1",
|
||||
"turn_id": "0",
|
||||
"item_id": "rs_test",
|
||||
"delta": " thinking"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1_050i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "reasoning_content_delta",
|
||||
"thread_id": "thread-1",
|
||||
"turn_id": "0",
|
||||
"item_id": "rs_test",
|
||||
"delta": " about"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_ms": 1_100i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": {
|
||||
"type": "reasoning_content_delta",
|
||||
"thread_id": "thread-1",
|
||||
"turn_id": "0",
|
||||
"item_id": "rs_test",
|
||||
"delta": " streaming state"
|
||||
}
|
||||
}
|
||||
}
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn single_message() -> Vec<LogEntry> {
|
||||
logs_from_value(json!([
|
||||
{
|
||||
"timestamp_ms": 1_000i64,
|
||||
"event": {
|
||||
"id": "0",
|
||||
"msg": { "type": "agent_message", "message": "only event" }
|
||||
}
|
||||
}
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_full(session_id: &ConversationId, logs: &[LogEntry], total: usize, more: bool) -> String {
|
||||
let earliest_ms = logs.first().map(|e| e.timestamp_ms);
|
||||
let latest_ms = logs.last().map(|e| e.timestamp_ms);
|
||||
render_logs_as_text(
|
||||
*session_id,
|
||||
logs,
|
||||
earliest_ms,
|
||||
latest_ms,
|
||||
logs.len(),
|
||||
total,
|
||||
more,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_with_lines(
|
||||
session_id: &ConversationId,
|
||||
logs: &[LogEntry],
|
||||
total: usize,
|
||||
more: bool,
|
||||
max_lines: usize,
|
||||
direction: PageDirection,
|
||||
) -> String {
|
||||
let earliest_ms = logs.first().map(|e| e.timestamp_ms);
|
||||
let latest_ms = logs.last().map(|e| e.timestamp_ms);
|
||||
render_logs_as_text_with_max_lines(
|
||||
*session_id,
|
||||
logs,
|
||||
earliest_ms,
|
||||
latest_ms,
|
||||
logs.len(),
|
||||
total,
|
||||
more,
|
||||
max_lines,
|
||||
direction,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_logs_paging_tail_vs_full_exec_sleep() {
|
||||
// Demonstrate that a one-line tail view is a suffix of the
|
||||
// full transcript, and that a generous max_lines reproduces
|
||||
// the full rendering.
|
||||
let logs = fixtures::exec_sleep();
|
||||
let session = session("019a7073-88e5-7461-93a0-adf67192b17b");
|
||||
let total = 21; // from exp4-real-run1
|
||||
let more = true;
|
||||
|
||||
let full = render_full(&session, &logs, total, more);
|
||||
let tail_one = render_with_lines(&session, &logs, total, more, 1, PageDirection::Backward);
|
||||
let tail_many = render_with_lines(&session, &logs, total, more, 30, PageDirection::Backward);
|
||||
|
||||
assert_eq!(full, tail_many);
|
||||
|
||||
// Snapshot the one-line tail to make the behavior obvious.
|
||||
assert_snapshot!(
|
||||
tail_one,
|
||||
@r###"Session 019a7073-88e5-7461-93a0-adf67192b17b • status=waiting_on_tool • older_logs=true • at_latest=true
|
||||
2025-11-11T01:06:53.628Z 🛠 exec bash -lc sleep 60 · cwd=/Users/friel/code/codex · running (0.0s)"###
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_logs_paging_line_by_line_exec_sleep() {
|
||||
// Show what the transcript looks like as we increase the
|
||||
// line budget from 1 to 3 (backward paging), to mimic a
|
||||
// user scrolling back line-by-line.
|
||||
let logs = fixtures::exec_sleep();
|
||||
let session = session("019a7073-88e5-7461-93a0-adf67192b17b");
|
||||
let total = 21; // from exp4-real-run1
|
||||
let more = true;
|
||||
|
||||
let mut pages = Vec::new();
|
||||
for max_lines in 1..=3 {
|
||||
let rendered = render_with_lines(
|
||||
&session,
|
||||
&logs,
|
||||
total,
|
||||
more,
|
||||
max_lines,
|
||||
PageDirection::Backward,
|
||||
);
|
||||
pages.push(format!("lines={max_lines}\n{rendered}"));
|
||||
}
|
||||
|
||||
let snapshot = pages.join("\n---\n");
|
||||
|
||||
assert_snapshot!(
|
||||
snapshot,
|
||||
@r###"lines=1
|
||||
Session 019a7073-88e5-7461-93a0-adf67192b17b • status=waiting_on_tool • older_logs=true • at_latest=true
|
||||
2025-11-11T01:06:53.628Z 🛠 exec bash -lc sleep 60 · cwd=/Users/friel/code/codex · running (0.0s)
|
||||
---
|
||||
lines=2
|
||||
Session 019a7073-88e5-7461-93a0-adf67192b17b • status=waiting_on_tool • older_logs=true • at_latest=true
|
||||
2025-11-11T01:06:53.442Z Reasoning summary: **Evaluating safe shell command execution**
|
||||
2025-11-11T01:06:53.628Z 🛠 exec bash -lc sleep 60 · cwd=/Users/friel/code/codex · running (0.0s)
|
||||
---
|
||||
lines=3
|
||||
Session 019a7073-88e5-7461-93a0-adf67192b17b • status=waiting_on_tool • older_logs=true • at_latest=true
|
||||
2025-11-11T01:06:53.424Z Thinking: ** (1 delta)
|
||||
2025-11-11T01:06:53.442Z Reasoning summary: **Evaluating safe shell command execution**
|
||||
2025-11-11T01:06:53.628Z 🛠 exec bash -lc sleep 60 · cwd=/Users/friel/code/codex · running (0.0s)"###
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_logs_snapshot_baseline() {
|
||||
// Grounded in exp1-real-run1 first subagent_logs response (t=0).
|
||||
let logs = fixtures::baseline();
|
||||
let rendered = render_full(
|
||||
&session("019a7075-0760-79c2-8dd1-985772995ecf"),
|
||||
&logs,
|
||||
logs.len(),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
rendered,
|
||||
@r###"Session 019a7075-0760-79c2-8dd1-985772995ecf • status=idle • older_logs=false • at_latest=true
|
||||
2025-11-11T01:08:31.766Z Assistant: Hello world
|
||||
2025-11-11T01:08:31.766Z Thinking: **Identifying sandbox requirements**
|
||||
2025-11-11T01:08:31.766Z Task complete"###
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_logs_snapshot_exec_sleep_command() {
|
||||
// Grounded in exp4-real-run1 first subagent_logs response (t=0).
|
||||
let logs = fixtures::exec_sleep();
|
||||
let rendered = render_full(
|
||||
&session("019a7073-88e5-7461-93a0-adf67192b17b"),
|
||||
&logs,
|
||||
logs.len(),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
rendered,
|
||||
@r###"Session 019a7073-88e5-7461-93a0-adf67192b17b • status=waiting_on_tool • older_logs=false • at_latest=true
|
||||
2025-11-11T01:06:53.424Z Thinking: ** (1 delta)
|
||||
2025-11-11T01:06:53.442Z Reasoning summary: **Evaluating safe shell command execution**
|
||||
2025-11-11T01:06:53.628Z 🛠 exec bash -lc sleep 60 · cwd=/Users/friel/code/codex · running (0.0s)"###
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_logs_snapshot_streaming_deltas() {
|
||||
// Grounded in exp5-real-run1 agent_message_content_delta stream (t≈?s).
|
||||
let logs = fixtures::streaming_deltas();
|
||||
let rendered = render_full(
|
||||
&session("019a713e-6ce4-73e0-bf9b-e070890e3790"),
|
||||
&logs,
|
||||
logs.len(),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
rendered,
|
||||
@r###"Session 019a713e-6ce4-73e0-bf9b-e070890e3790 • status=working • older_logs=false • at_latest=true
|
||||
2025-11-11T04:48:47.148Z Assistant (typing): is composing a longer answer (5 chunks)"###
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_logs_snapshot_reasoning_stream() {
|
||||
// Synthetic example of mid-reasoning without a summary yet.
|
||||
let logs = fixtures::reasoning_stream();
|
||||
let rendered = render_full(
|
||||
&session("019a713e-eeee-73e0-bf9b-e070890e3790"),
|
||||
&logs,
|
||||
logs.len(),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
rendered,
|
||||
@r###"Session 019a713e-eeee-73e0-bf9b-e070890e3790 • status=working • older_logs=false • at_latest=true
|
||||
1970-01-01T00:00:01.100Z Thinking: thinking about streaming state (3 deltas)"###
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_logs_snapshot_no_older_history() {
|
||||
// Minimal case: single assistant message, no older history, at latest.
|
||||
let logs = fixtures::single_message();
|
||||
let rendered = render_full(
|
||||
&session("019a9999-aaaa-bbbb-cccc-ddddeeeeffff"),
|
||||
&logs,
|
||||
logs.len(),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
rendered,
|
||||
@r###"Session 019a9999-aaaa-bbbb-cccc-ddddeeeeffff • status=idle • older_logs=false • at_latest=true
|
||||
1970-01-01T00:00:01.000Z Assistant: only event"###
|
||||
);
|
||||
}
|
||||
|
||||
// Note: payload-shape and paging semantics (since_ms/before_ms/limit/max_bytes)
|
||||
// are covered in focused unit tests in core/src/tools/handlers/subagent.rs.
|
||||
@@ -49,6 +49,7 @@ mod seatbelt;
|
||||
mod shell_serialization;
|
||||
mod stream_error_allows_next_turn;
|
||||
mod stream_no_completed;
|
||||
mod subagent_exec_events;
|
||||
mod text_encoding_fix;
|
||||
mod tool_harness;
|
||||
mod tool_parallelism;
|
||||
|
||||
74
codex-rs/core/tests/suite/subagent_exec_events.rs
Normal file
74
codex-rs/core/tests/suite/subagent_exec_events.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_exec_command_pair;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn root_subagent_tool_emits_exec_events() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex();
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let call_id = "subagent-call";
|
||||
let args = json!({});
|
||||
|
||||
// First completion triggers the subagent tool call.
|
||||
core_test_support::responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "subagent_list", &args.to_string()),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
// Second completion finishes the turn.
|
||||
core_test_support::responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "spawn one please".to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: codex_core::protocol::AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (begin, end) = wait_for_exec_command_pair(&test.codex, call_id).await;
|
||||
|
||||
assert_eq!(
|
||||
begin.command.first().map(String::as_str),
|
||||
Some("Listed subagents"),
|
||||
);
|
||||
assert_eq!(end.call_id, begin.call_id);
|
||||
assert_eq!(end.exit_code, 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -42,6 +42,10 @@ These are entities exit on the codex backend. The intent of this section is to e
|
||||
|
||||
The term "UI" is used to refer to the application driving `Codex`. This may be the CLI / TUI chat-like interface that users operate, or it may be a GUI interface like a VSCode extension. The UI is external to `Codex`, as `Codex` is intended to be operated by arbitrary UI implementations.
|
||||
|
||||
### Agent identifiers
|
||||
|
||||
Every participant in a session (the root UI thread plus each spawned/forked child) is assigned a monotonically increasing numeric `AgentId`. Agent `0` is always the root thread. Subagents inherit their parent's `AgentId` as `parent_agent_id` so UIs can correlate trees even when conversations are forked or exported. These IDs are surfaced in `SubagentSummary` payloads and in a dedicated inbox event described below.
|
||||
|
||||
When a `Turn` completes, the `response_id` from the `Model`'s final `response.completed` message is stored in the `Session` state to resume the thread given the next `Op::UserInput`. The `response_id` is also returned in the `EventMsg::TurnComplete` to the UI, which can be used to fork the thread from an earlier point by providing it in the `Op::UserInput`.
|
||||
|
||||
Since only 1 `Task` can be run at a time, for parallel tasks it is recommended that a single `Codex` be run for each thread of work.
|
||||
@@ -75,6 +79,11 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc
|
||||
- `EventMsg::Error` – A task stopped with an error
|
||||
- `EventMsg::Warning` – A non-fatal warning that the client should surface to the user
|
||||
- `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the task. This can be used to continue the task at a later point in time, perhaps with additional user input.
|
||||
- `EventMsg::SubagentLifecycle` – Emits `SubagentSummary` payloads (now including `agent_id`, `parent_agent_id`, and pending inbox counts) whenever a child session is created, updates status/reasoning headers, or is removed.
|
||||
These lifecycle events now persist in rollout files so `codex resume` can restore prior subagent state (attachments on spawn/fork and detach on cancel/prune).
|
||||
- `EventMsg::AgentInbox` – Notifies the UI when a subagent’s inbox depth changes, e.g., after the parent sends an interrupt. Contains the target `agent_id`, `session_id`, and the counts of pending regular vs interrupt messages so UIs can render badges without polling.
|
||||
|
||||
Subagent tool reminders: `subagent_await` accepts an optional `timeout_s` capped at 1,800 s (30 minutes). Omit it or pass 0 to use the 30-minute default; prefer at least 300 s and use backoff (30s → 60s → 120s) so you can check on children, log progress, or deliver interrupts instead of parking for the full cap.
|
||||
|
||||
The `response_id` returned from each task matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work.
|
||||
|
||||
|
||||
@@ -91,6 +91,9 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Resume a previous session by id or pick the most recent with --last.
|
||||
Resume(ResumeArgs),
|
||||
|
||||
/// Fork an existing session into a new branch and immediately run a prompt.
|
||||
Fork(ForkArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -109,6 +112,17 @@ pub struct ResumeArgs {
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ForkArgs {
|
||||
/// Session id (UUID) to fork into a new branch.
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
pub session_id: String,
|
||||
|
||||
/// Prompt to send after forking. If `-` is used, read from stdin.
|
||||
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum Color {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use codex_common::elapsed::format_duration;
|
||||
use codex_common::elapsed::format_elapsed;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::AgentInboxEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningRawContentEvent;
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
@@ -18,11 +19,14 @@ use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::SubagentLifecycleEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use codex_core::protocol::TurnDiffEvent;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::AgentId;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::num_format::format_with_separators;
|
||||
use owo_colors::OwoColorize;
|
||||
use owo_colors::Style;
|
||||
@@ -43,6 +47,7 @@ use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20;
|
||||
pub(crate) struct EventProcessorWithHumanOutput {
|
||||
call_id_to_patch: HashMap<String, PatchApplyBegin>,
|
||||
subagents: HashMap<ConversationId, SubagentCliInfo>,
|
||||
|
||||
// To ensure that --color=never is respected, ANSI escapes _must_ be added
|
||||
// using .style() with one of these fields. If you need a new style, add a
|
||||
@@ -65,6 +70,11 @@ pub(crate) struct EventProcessorWithHumanOutput {
|
||||
final_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SubagentCliInfo {
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
impl EventProcessorWithHumanOutput {
|
||||
pub(crate) fn create_with_ansi(
|
||||
with_ansi: bool,
|
||||
@@ -76,6 +86,7 @@ impl EventProcessorWithHumanOutput {
|
||||
if with_ansi {
|
||||
Self {
|
||||
call_id_to_patch,
|
||||
subagents: HashMap::new(),
|
||||
bold: Style::new().bold(),
|
||||
italic: Style::new().italic(),
|
||||
dimmed: Style::new().dimmed(),
|
||||
@@ -93,6 +104,7 @@ impl EventProcessorWithHumanOutput {
|
||||
} else {
|
||||
Self {
|
||||
call_id_to_patch,
|
||||
subagents: HashMap::new(),
|
||||
bold: Style::new(),
|
||||
italic: Style::new(),
|
||||
dimmed: Style::new(),
|
||||
@@ -546,6 +558,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
ts_msg!(self, "task aborted: review ended");
|
||||
}
|
||||
},
|
||||
EventMsg::SubagentLifecycle(ev) => self.log_subagent_lifecycle(ev),
|
||||
EventMsg::AgentInbox(ev) => self.log_agent_inbox(ev),
|
||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||
EventMsg::WebSearchBegin(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
@@ -595,6 +609,106 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl EventProcessorWithHumanOutput {
|
||||
fn log_subagent_lifecycle(&mut self, event: SubagentLifecycleEvent) {
|
||||
match event {
|
||||
SubagentLifecycleEvent::Created(created) => {
|
||||
let name = self.render_subagent_name(
|
||||
&created.subagent.session_id,
|
||||
created.subagent.agent_id,
|
||||
created.subagent.label.as_deref(),
|
||||
);
|
||||
self.subagents.insert(
|
||||
created.subagent.session_id,
|
||||
SubagentCliInfo {
|
||||
label: created.subagent.label.clone(),
|
||||
},
|
||||
);
|
||||
ts_msg!(
|
||||
self,
|
||||
"[#{}] {} created via {:?}",
|
||||
created.subagent.agent_id,
|
||||
name.style(self.cyan),
|
||||
created.subagent.origin
|
||||
);
|
||||
}
|
||||
SubagentLifecycleEvent::Status(status) => {
|
||||
let name = self.render_subagent_name(&status.session_id, status.agent_id, None);
|
||||
ts_msg!(
|
||||
self,
|
||||
"[#{}] {} status → {:?}",
|
||||
status.agent_id,
|
||||
name.style(self.cyan),
|
||||
status.status
|
||||
);
|
||||
}
|
||||
SubagentLifecycleEvent::ReasoningHeader(reasoning) => {
|
||||
let name =
|
||||
self.render_subagent_name(&reasoning.session_id, reasoning.agent_id, None);
|
||||
ts_msg!(
|
||||
self,
|
||||
"[#{}] {} header: {}",
|
||||
reasoning.agent_id,
|
||||
name.style(self.cyan),
|
||||
reasoning.reasoning_header.style(self.italic)
|
||||
);
|
||||
}
|
||||
SubagentLifecycleEvent::Deleted(removed) => {
|
||||
let name = self.render_subagent_name(&removed.session_id, removed.agent_id, None);
|
||||
self.subagents.remove(&removed.session_id);
|
||||
ts_msg!(
|
||||
self,
|
||||
"[#{}] {} removed",
|
||||
removed.agent_id,
|
||||
name.style(self.cyan)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_agent_inbox(&mut self, event: AgentInboxEvent) {
|
||||
let name = self.render_subagent_name(&event.session_id, event.agent_id, None);
|
||||
ts_msg!(
|
||||
self,
|
||||
"[#{}] {} inbox: {} msg{}, {} interrupt{}",
|
||||
event.agent_id,
|
||||
name.style(self.cyan),
|
||||
event.pending_messages,
|
||||
if event.pending_messages == 1 { "" } else { "s" },
|
||||
event.pending_interrupts,
|
||||
if event.pending_interrupts == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn render_subagent_name(
|
||||
&self,
|
||||
session_id: &ConversationId,
|
||||
agent_id: AgentId,
|
||||
explicit: Option<&str>,
|
||||
) -> String {
|
||||
if let Some(name) = explicit
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(std::string::ToString::to_string)
|
||||
.or_else(|| {
|
||||
self.subagents
|
||||
.get(session_id)
|
||||
.and_then(|info| info.label.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(std::string::ToString::to_string)
|
||||
})
|
||||
{
|
||||
return name;
|
||||
}
|
||||
let short = session_id.to_string().chars().take(8).collect::<String>();
|
||||
format!("subagent #{agent_id} ({short})")
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_command(command: &[String]) -> String {
|
||||
try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "))
|
||||
|
||||
@@ -10,6 +10,8 @@ mod event_processor_with_human_output;
|
||||
pub mod event_processor_with_jsonl_output;
|
||||
pub mod exec_events;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::anyhow;
|
||||
pub use cli::Cli;
|
||||
use codex_common::oss::ensure_oss_provider_ready;
|
||||
use codex_common::oss::get_default_model_for_oss_provider;
|
||||
@@ -18,6 +20,7 @@ use codex_core::ConversationManager;
|
||||
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::auth::enforce_login_restrictions;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
@@ -31,6 +34,7 @@ use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
use event_processor_with_jsonl_output::EventProcessorWithJsonOutput;
|
||||
@@ -39,6 +43,7 @@ use serde_json::Value;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use supports_color::Stream;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
@@ -97,6 +102,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
});
|
||||
resume_prompt.or(prompt)
|
||||
}
|
||||
Some(ExecCommand::Fork(args)) => args.prompt.clone().or(prompt),
|
||||
None => prompt,
|
||||
};
|
||||
|
||||
@@ -240,6 +246,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
compact_prompt: None,
|
||||
max_active_subagents: None,
|
||||
root_agent_uses_user_messages: None,
|
||||
subagent_root_inbox_autosubmit: None,
|
||||
subagent_inbox_inject_before_tools: None,
|
||||
include_apply_patch_tool: None,
|
||||
show_raw_agent_reasoning: oss.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
@@ -323,27 +333,39 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
);
|
||||
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
|
||||
|
||||
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
|
||||
// Handle resume/fork subcommands by resolving a rollout path and using the explicit resume API.
|
||||
let NewConversation {
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
session_configured,
|
||||
} = if let Some(ExecCommand::Resume(args)) = command {
|
||||
let resume_path = resolve_resume_path(&config, &args).await?;
|
||||
|
||||
if let Some(path) = resume_path {
|
||||
conversation_manager
|
||||
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
|
||||
.await?
|
||||
} else {
|
||||
} = match &command {
|
||||
Some(ExecCommand::Resume(args)) => {
|
||||
let resume_path = resolve_resume_path(&config, args).await?;
|
||||
if let Some(path) = resume_path {
|
||||
conversation_manager
|
||||
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
|
||||
.await?
|
||||
} else {
|
||||
conversation_manager
|
||||
.new_conversation(config.clone())
|
||||
.await?
|
||||
}
|
||||
}
|
||||
Some(ExecCommand::Fork(args)) => {
|
||||
let path = resolve_fork_path(&config, &args.session_id).await?;
|
||||
fork_conversation_from_rollout(
|
||||
&conversation_manager,
|
||||
&config,
|
||||
auth_manager.clone(),
|
||||
path,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
conversation_manager
|
||||
.new_conversation(config.clone())
|
||||
.await?
|
||||
}
|
||||
} else {
|
||||
conversation_manager
|
||||
.new_conversation(config.clone())
|
||||
.await?
|
||||
};
|
||||
// Print the effective configuration and prompt so users can see what Codex
|
||||
// is using.
|
||||
@@ -467,6 +489,32 @@ async fn resolve_resume_path(
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_fork_path(config: &Config, session_id: &str) -> anyhow::Result<PathBuf> {
|
||||
find_conversation_path_by_id_str(&config.codex_home, session_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("No session with id {session_id} found"))
|
||||
}
|
||||
|
||||
async fn fork_conversation_from_rollout(
|
||||
conversation_manager: &ConversationManager,
|
||||
config: &Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
path: PathBuf,
|
||||
) -> anyhow::Result<NewConversation> {
|
||||
let history = RolloutRecorder::get_rollout_history(&path)
|
||||
.await
|
||||
.context("failed to read session history for fork")?;
|
||||
let fork_history = match history {
|
||||
InitialHistory::New => InitialHistory::New,
|
||||
InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history),
|
||||
InitialHistory::Forked(items) => InitialHistory::Forked(items),
|
||||
};
|
||||
conversation_manager
|
||||
.resume_conversation_with_history(config.clone(), fork_history, auth_manager)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
|
||||
let path = path?;
|
||||
|
||||
|
||||
@@ -642,6 +642,7 @@ fn exec_command_end_success_produces_completed_command_item() {
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
interaction_input: None,
|
||||
}),
|
||||
);
|
||||
@@ -714,6 +715,7 @@ fn exec_command_end_failure_produces_failed_command_item() {
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
interaction_input: None,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -309,3 +309,70 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
|
||||
assert!(content.contains(&marker2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_fork_creates_new_session() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
let fixture =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
|
||||
|
||||
// Seed conversation.
|
||||
let marker = format!("fork-source-{}", Uuid::new_v4());
|
||||
let prompt = format!("echo {marker}");
|
||||
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg(&prompt)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let sessions_dir = test.home_path().join("sessions");
|
||||
let source_path = find_session_file_containing_marker(&sessions_dir, &marker)
|
||||
.expect("no session file found after source run");
|
||||
let source_id = extract_conversation_id(&source_path);
|
||||
assert!(
|
||||
!source_id.is_empty(),
|
||||
"missing conversation id in source session"
|
||||
);
|
||||
|
||||
// Fork with a new prompt.
|
||||
let marker2 = format!("fork-branch-{}", Uuid::new_v4());
|
||||
let prompt2 = format!("echo {marker2}");
|
||||
|
||||
test.cmd()
|
||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||
.env("OPENAI_BASE_URL", "http://unused.local")
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("-C")
|
||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||
.arg("fork")
|
||||
.arg(&source_id)
|
||||
.arg(&prompt2)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let forked_path = find_session_file_containing_marker(&sessions_dir, &marker2)
|
||||
.expect("no forked session file containing new marker");
|
||||
assert_ne!(
|
||||
forked_path, source_path,
|
||||
"fork should produce a new session file"
|
||||
);
|
||||
|
||||
let source_content = std::fs::read_to_string(&source_path)?;
|
||||
assert!(
|
||||
!source_content.contains(&marker2),
|
||||
"source session must remain unchanged"
|
||||
);
|
||||
|
||||
let forked_content = std::fs::read_to_string(&forked_path)?;
|
||||
assert!(
|
||||
forked_content.contains(&marker) && forked_content.contains(&marker2),
|
||||
"forked session should contain both original and new prompts"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
BIN
codex-rs/image.png
Normal file
BIN
codex-rs/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -28,7 +28,7 @@ app-server-test-client *args:
|
||||
|
||||
# format code
|
||||
fmt:
|
||||
cargo fmt -- --config imports_granularity=Item
|
||||
cargo +nightly fmt -- --config imports_granularity=Item
|
||||
|
||||
fix *args:
|
||||
cargo clippy --fix --all-features --tests --allow-dirty "$@"
|
||||
|
||||
@@ -166,6 +166,10 @@ impl CodexToolCallParam {
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
compact_prompt,
|
||||
max_active_subagents: None,
|
||||
root_agent_uses_user_messages: None,
|
||||
subagent_root_inbox_autosubmit: None,
|
||||
subagent_inbox_inject_before_tools: None,
|
||||
include_apply_patch_tool: None,
|
||||
show_raw_agent_reasoning: None,
|
||||
tools_web_search_request: None,
|
||||
|
||||
@@ -302,7 +302,9 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::UndoStarted(_)
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::DeprecationNotice(_) => {
|
||||
| EventMsg::DeprecationNotice(_)
|
||||
| EventMsg::SubagentLifecycle(_)
|
||||
| EventMsg::AgentInbox(_) => {
|
||||
// For now, we do not do anything extra for these
|
||||
// events. Note that
|
||||
// send(codex_event_to_notification(&event)) above has
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod account;
|
||||
mod conversation_id;
|
||||
pub use conversation_id::ConversationId;
|
||||
/// Numeric identifier for agents/subagents (agent 0 is always the root UI thread).
|
||||
pub type AgentId = u64;
|
||||
pub mod approvals;
|
||||
pub mod config_types;
|
||||
pub mod custom_prompts;
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::AgentId;
|
||||
use crate::ConversationId;
|
||||
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
@@ -470,6 +471,12 @@ pub enum EventMsg {
|
||||
/// Raw chain-of-thought from agent.
|
||||
AgentReasoningRawContent(AgentReasoningRawContentEvent),
|
||||
|
||||
/// Lifecycle update for a subagent (spawned/forked/resumed child session).
|
||||
SubagentLifecycle(SubagentLifecycleEvent),
|
||||
|
||||
/// Notification that a subagent's inbox depth changed (e.g., parent sent an interrupt).
|
||||
AgentInbox(AgentInboxEvent),
|
||||
|
||||
/// Agent reasoning content delta event from agent.
|
||||
AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent),
|
||||
/// Signaled when the model begins a new reasoning summary section (e.g., a new titled block).
|
||||
@@ -969,6 +976,92 @@ pub struct AgentReasoningRawContentEvent {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SubagentLifecycleOrigin {
|
||||
Spawn,
|
||||
Fork,
|
||||
SendMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq, Eq, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SubagentLifecycleStatus {
|
||||
Queued,
|
||||
Running,
|
||||
Ready,
|
||||
Idle,
|
||||
Failed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SubagentSummary {
|
||||
pub agent_id: AgentId,
|
||||
pub parent_agent_id: Option<AgentId>,
|
||||
pub session_id: ConversationId,
|
||||
pub parent_session_id: Option<ConversationId>,
|
||||
pub origin: SubagentLifecycleOrigin,
|
||||
pub status: SubagentLifecycleStatus,
|
||||
pub label: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub reasoning_header: Option<String>,
|
||||
pub started_at_ms: i64,
|
||||
pub pending_messages: usize,
|
||||
pub pending_interrupts: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[serde(tag = "event", rename_all = "snake_case")]
|
||||
#[ts(export)]
|
||||
pub enum SubagentLifecycleEvent {
|
||||
Created(SubagentCreatedEvent),
|
||||
Status(SubagentStatusEvent),
|
||||
ReasoningHeader(SubagentReasoningHeaderEvent),
|
||||
Deleted(SubagentRemovedEvent),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SubagentCreatedEvent {
|
||||
pub subagent: SubagentSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SubagentStatusEvent {
|
||||
pub agent_id: AgentId,
|
||||
pub session_id: ConversationId,
|
||||
pub status: SubagentLifecycleStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SubagentReasoningHeaderEvent {
|
||||
pub agent_id: AgentId,
|
||||
pub session_id: ConversationId,
|
||||
pub reasoning_header: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SubagentRemovedEvent {
|
||||
pub agent_id: AgentId,
|
||||
pub session_id: ConversationId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[ts(export)]
|
||||
pub struct AgentInboxEvent {
|
||||
pub agent_id: AgentId,
|
||||
pub session_id: ConversationId,
|
||||
pub pending_messages: usize,
|
||||
pub pending_interrupts: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct AgentReasoningRawContentDeltaEvent {
|
||||
pub delta: String,
|
||||
@@ -1294,6 +1387,9 @@ pub struct ExecCommandBeginEvent {
|
||||
/// Where the command originated. Defaults to Agent for backward compatibility.
|
||||
#[serde(default)]
|
||||
pub source: ExecCommandSource,
|
||||
/// Whether the command was explicitly requested as a local user shell command.
|
||||
#[serde(default)]
|
||||
pub is_user_shell_command: bool,
|
||||
/// Raw input sent to a unified exec session (if this is an interaction event).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -13,6 +14,9 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::time::Duration;
|
||||
|
||||
mod approval_overlay;
|
||||
@@ -48,6 +52,7 @@ pub(crate) enum CancellationEvent {
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::protocol::SubagentLifecycleStatus;
|
||||
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
@@ -75,6 +80,8 @@ pub(crate) struct BottomPane {
|
||||
status: Option<StatusIndicatorWidget>,
|
||||
/// Queued user messages to show above the composer while a turn is running.
|
||||
queued_user_messages: QueuedUserMessages,
|
||||
subagent_summaries: SubagentSummariesWidget,
|
||||
subagent_count: usize,
|
||||
context_window_percent: Option<i64>,
|
||||
}
|
||||
|
||||
@@ -115,6 +122,8 @@ impl BottomPane {
|
||||
ctrl_c_quit_hint: false,
|
||||
status: None,
|
||||
queued_user_messages: QueuedUserMessages::new(),
|
||||
subagent_summaries: SubagentSummariesWidget::default(),
|
||||
subagent_count: 0,
|
||||
esc_backtrack_hint: false,
|
||||
animations_enabled,
|
||||
context_window_percent: None,
|
||||
@@ -278,6 +287,16 @@ impl BottomPane {
|
||||
self.status.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn subagent_entries(&self) -> &[SubagentDisplayEntry] {
|
||||
&self.subagent_summaries.entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn subagent_count(&self) -> usize {
|
||||
self.subagent_count
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
self.esc_backtrack_hint = true;
|
||||
self.composer.set_esc_backtrack_hint(true);
|
||||
@@ -308,11 +327,12 @@ impl BottomPane {
|
||||
self.animations_enabled,
|
||||
));
|
||||
}
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(true);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(true);
|
||||
status.set_subagent_count(self.subagent_count);
|
||||
}
|
||||
self.request_redraw();
|
||||
} else {
|
||||
// Hide the status indicator when a task completes, but keep other modal views.
|
||||
self.hide_status_indicator();
|
||||
@@ -328,12 +348,18 @@ impl BottomPane {
|
||||
|
||||
pub(crate) fn ensure_status_indicator(&mut self) {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
let mut status = StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
self.animations_enabled,
|
||||
));
|
||||
);
|
||||
status.set_subagent_count(self.subagent_count);
|
||||
self.status = Some(status);
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_subagent_count(self.subagent_count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +392,23 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn update_subagent_summaries(&mut self, entries: Vec<SubagentDisplayEntry>) {
|
||||
if self.subagent_summaries.update(entries) {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_subagent_counts(&mut self, count: usize) {
|
||||
if self.subagent_count == count {
|
||||
return;
|
||||
}
|
||||
self.subagent_count = count;
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_subagent_count(count);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Update custom prompts available for the slash popup.
|
||||
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
||||
self.composer.set_custom_prompts(prompts);
|
||||
@@ -495,14 +538,17 @@ impl BottomPane {
|
||||
let mut flex = FlexRenderable::new();
|
||||
if let Some(status) = &self.status {
|
||||
flex.push(0, RenderableItem::Borrowed(status));
|
||||
if !self.subagent_summaries.is_empty() {
|
||||
flex.push(0, RenderableItem::Borrowed(&self.subagent_summaries));
|
||||
}
|
||||
}
|
||||
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
|
||||
if self.status.is_some() || !self.queued_user_messages.messages.is_empty() {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
let mut flex2 = FlexRenderable::new();
|
||||
flex2.push(1, RenderableItem::Owned(flex.into()));
|
||||
flex2.push(0, RenderableItem::Borrowed(&self.composer));
|
||||
flex2.push(0, RenderableItem::Owned(flex.into()));
|
||||
flex2.push(1, RenderableItem::Borrowed(&self.composer));
|
||||
RenderableItem::Owned(Box::new(flex2))
|
||||
}
|
||||
}
|
||||
@@ -520,6 +566,108 @@ impl Renderable for BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SubagentSummariesWidget {
|
||||
entries: Vec<SubagentDisplayEntry>,
|
||||
}
|
||||
|
||||
impl SubagentSummariesWidget {
|
||||
fn update(&mut self, entries: Vec<SubagentDisplayEntry>) -> bool {
|
||||
if self.entries == entries {
|
||||
return false;
|
||||
}
|
||||
self.entries = entries;
|
||||
true
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for SubagentSummariesWidget {
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
self.entries.len().min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
for (idx, entry) in self.entries.iter().enumerate() {
|
||||
let y = area.y + idx as u16;
|
||||
if y >= area.bottom() {
|
||||
break;
|
||||
}
|
||||
let line_area = Rect::new(area.x, y, area.width, 1);
|
||||
entry.render(line_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) enum SubagentDisplayEntryKind {
|
||||
Summary,
|
||||
Item,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) struct SubagentDisplayEntry {
|
||||
pub(crate) kind: SubagentDisplayEntryKind,
|
||||
pub(crate) label: String,
|
||||
pub(crate) detail: Option<String>,
|
||||
pub(crate) status: Option<SubagentLifecycleStatus>,
|
||||
}
|
||||
|
||||
impl SubagentDisplayEntry {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let line = match self.kind {
|
||||
SubagentDisplayEntryKind::Summary => {
|
||||
let mut spans: Vec<Span> = vec![" • ".dim(), self.label.clone().dim()];
|
||||
if let Some(detail) = &self.detail {
|
||||
spans.push(" — ".dim());
|
||||
spans.push(detail.clone().dim());
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
SubagentDisplayEntryKind::Item => {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
spans.push(" • ".dim());
|
||||
spans.push(self.label.clone().bold());
|
||||
if let Some(status) = self.status {
|
||||
spans.push(" ".into());
|
||||
spans.push(status_to_span(status));
|
||||
}
|
||||
if let Some(detail) = &self.detail {
|
||||
spans.push(" — ".dim());
|
||||
let should_shimmer =
|
||||
matches!(self.status, Some(SubagentLifecycleStatus::Running));
|
||||
if should_shimmer {
|
||||
let shimmer = shimmer_spans(detail);
|
||||
if shimmer.is_empty() {
|
||||
spans.push(detail.clone().into());
|
||||
} else {
|
||||
spans.extend(shimmer);
|
||||
}
|
||||
} else {
|
||||
spans.push(detail.clone().into());
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
};
|
||||
line.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_span(status: SubagentLifecycleStatus) -> Span<'static> {
|
||||
match status {
|
||||
SubagentLifecycleStatus::Queued => "queued".blue(),
|
||||
SubagentLifecycleStatus::Running => "running".green(),
|
||||
SubagentLifecycleStatus::Ready => "ready".cyan(),
|
||||
SubagentLifecycleStatus::Idle => "idle".dim(),
|
||||
SubagentLifecycleStatus::Failed => "failed".red(),
|
||||
SubagentLifecycleStatus::Canceled => "canceled".magenta(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -547,6 +695,16 @@ mod tests {
|
||||
snapshot_buffer(&buf)
|
||||
}
|
||||
|
||||
fn render_subagent_summaries_widget(entries: Vec<SubagentDisplayEntry>, width: u16) -> String {
|
||||
let mut widget = SubagentSummariesWidget::default();
|
||||
widget.update(entries);
|
||||
let height = widget.desired_height(width).max(1);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
widget.render(area, &mut buf);
|
||||
snapshot_buffer(&buf)
|
||||
}
|
||||
|
||||
fn exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "1".to_string(),
|
||||
@@ -609,6 +767,181 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_summary_height_clamped() {
|
||||
let mut widget = SubagentSummariesWidget::default();
|
||||
let entries: Vec<SubagentDisplayEntry> = (0..100_000)
|
||||
.map(|i| SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: format!("agent-{i}"),
|
||||
detail: None,
|
||||
status: None,
|
||||
})
|
||||
.collect();
|
||||
widget.update(entries);
|
||||
assert_eq!(widget.desired_height(80), u16::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_shimmer_only_when_running() {
|
||||
let mut entry = SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: "agent".to_string(),
|
||||
detail: Some("working".to_string()),
|
||||
status: Some(SubagentLifecycleStatus::Ready),
|
||||
};
|
||||
let area = Rect::new(0, 0, 40, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
entry.render(area, &mut buf);
|
||||
let line_ready = snapshot_buffer(&buf);
|
||||
|
||||
entry.status = Some(SubagentLifecycleStatus::Running);
|
||||
let mut buf_run = Buffer::empty(area);
|
||||
entry.render(area, &mut buf_run);
|
||||
let line_run = snapshot_buffer(&buf_run);
|
||||
|
||||
assert!(
|
||||
line_run.contains("working"),
|
||||
"running state should still show detail text"
|
||||
);
|
||||
assert!(
|
||||
line_run != line_ready,
|
||||
"running state should visually differ (shimmer) from non-running"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_summaries_widget_multi_state_snapshot() {
|
||||
let entries = vec![
|
||||
SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Summary,
|
||||
label: "4 subagents".into(),
|
||||
detail: Some("1 queued • 1 running • 1 ready • 1 idle • ".into()),
|
||||
status: None,
|
||||
},
|
||||
SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: "[#7] Catalog sync".into(),
|
||||
detail: Some("2 pending msgs".into()),
|
||||
status: Some(SubagentLifecycleStatus::Queued),
|
||||
},
|
||||
SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: "[#3] Dependency fetch".into(),
|
||||
detail: Some("Working on lockfiles".into()),
|
||||
status: Some(SubagentLifecycleStatus::Running),
|
||||
},
|
||||
SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: "[#2] Lint guard".into(),
|
||||
detail: Some("Ready for review".into()),
|
||||
status: Some(SubagentLifecycleStatus::Ready),
|
||||
},
|
||||
SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: "[#4] Metrics idle".into(),
|
||||
detail: Some("Waiting on data".into()),
|
||||
status: Some(SubagentLifecycleStatus::Idle),
|
||||
},
|
||||
SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: "[#5] Watchdog canceled".into(),
|
||||
detail: Some("Task canceled".into()),
|
||||
status: Some(SubagentLifecycleStatus::Canceled),
|
||||
},
|
||||
];
|
||||
|
||||
assert_snapshot!(
|
||||
"subagent_summaries_widget_multi_state_snapshot",
|
||||
render_subagent_summaries_widget(entries, 60)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_count_persists_across_status_hide() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_subagent_counts(3);
|
||||
pane.hide_status_indicator();
|
||||
pane.ensure_status_indicator();
|
||||
|
||||
let area = Rect::new(0, 0, 60, 2);
|
||||
let snapshot = render_snapshot(&pane, area);
|
||||
assert!(
|
||||
snapshot.contains("3 subagents"),
|
||||
"subagent count should survive hide/show of status indicator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_status_indicator_creates_widget_when_missing() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
});
|
||||
|
||||
assert!(
|
||||
!pane.status_indicator_visible(),
|
||||
"status indicator should start hidden"
|
||||
);
|
||||
|
||||
pane.set_subagent_counts(3);
|
||||
pane.ensure_status_indicator();
|
||||
assert!(pane.status_indicator_visible());
|
||||
|
||||
let area = Rect::new(0, 0, 60, 2);
|
||||
let snapshot = render_snapshot(&pane, area);
|
||||
assert!(snapshot.contains("3 subagents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_subagent_counts_updates_visible_status() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.set_subagent_counts(2);
|
||||
|
||||
let area = Rect::new(0, 0, 60, 3);
|
||||
let snapshot = render_snapshot(&pane, area);
|
||||
assert!(snapshot.contains("2 subagents"));
|
||||
|
||||
pane.set_subagent_counts(1);
|
||||
let snapshot = render_snapshot(&pane, area);
|
||||
assert!(snapshot.contains("1 subagent"));
|
||||
assert!(
|
||||
!snapshot.contains("2 subagents"),
|
||||
"updated status should replace the previous count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_shown_after_denied_while_task_running() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
assertion_line: 854
|
||||
expression: "render_subagent_summaries_widget(entries, 60)"
|
||||
---
|
||||
• 4 subagents — 1 queued • 1 running • 1 ready • 1 idle •
|
||||
• [#7] Catalog sync queued — 2 pending msgs
|
||||
• [#3] Dependency fetch running — Working on lockfiles
|
||||
• [#2] Lint guard ready — Ready for review
|
||||
• [#4] Metrics idle idle — Waiting on data
|
||||
• [#5] Watchdog canceled canceled — Task canceled
|
||||
@@ -11,6 +11,7 @@ use codex_core::config::types::Notifications;
|
||||
use codex_core::git_info::current_branch_name;
|
||||
use codex_core::git_info::local_git_branches;
|
||||
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
use codex_core::protocol::AgentInboxEvent;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
@@ -40,6 +41,9 @@ use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::SubagentLifecycleEvent;
|
||||
use codex_core::protocol::SubagentLifecycleStatus;
|
||||
use codex_core::protocol::SubagentSummary;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
@@ -52,6 +56,7 @@ use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_core::protocol::WebSearchBeginEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::AgentId;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -81,6 +86,8 @@ use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::SubagentDisplayEntry;
|
||||
use crate::bottom_pane::SubagentDisplayEntryKind;
|
||||
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::clipboard_paste::paste_image_to_temp_png;
|
||||
@@ -88,6 +95,7 @@ use crate::diff_render::display_path_for;
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::exec_cell::ExecCell;
|
||||
use crate::exec_cell::new_active_exec_command;
|
||||
use crate::exec_cell::parse_subagent_call;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
@@ -105,6 +113,8 @@ use crate::slash_command::SlashCommand;
|
||||
use crate::status::RateLimitSnapshotDisplay;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use serde_json::Value;
|
||||
mod interrupts;
|
||||
use self::interrupts::InterruptManager;
|
||||
mod agent;
|
||||
@@ -137,6 +147,7 @@ struct RunningCommand {
|
||||
command: Vec<String>,
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
source: ExecCommandSource,
|
||||
is_user_shell_command: bool,
|
||||
}
|
||||
|
||||
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0];
|
||||
@@ -286,6 +297,7 @@ pub(crate) struct ChatWidget {
|
||||
suppress_session_configured_redraw: bool,
|
||||
// User messages queued while a turn is in progress
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
subagent_states: HashMap<ConversationId, SubagentUiState>,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pending_notification: Option<Notification>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
@@ -325,6 +337,46 @@ impl From<&str> for UserMessage {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SubagentUiState {
|
||||
agent_id: AgentId,
|
||||
session_id: ConversationId,
|
||||
status: SubagentLifecycleStatus,
|
||||
label: Option<String>,
|
||||
summary: Option<String>,
|
||||
reasoning_header: Option<String>,
|
||||
started_at_ms: i64,
|
||||
pending_messages: usize,
|
||||
pending_interrupts: usize,
|
||||
}
|
||||
|
||||
impl From<SubagentSummary> for SubagentUiState {
|
||||
fn from(summary: SubagentSummary) -> Self {
|
||||
Self {
|
||||
agent_id: summary.agent_id,
|
||||
session_id: summary.session_id,
|
||||
status: summary.status,
|
||||
label: summary.label,
|
||||
summary: summary.summary,
|
||||
reasoning_header: summary.reasoning_header,
|
||||
started_at_ms: summary.started_at_ms,
|
||||
pending_messages: summary.pending_messages,
|
||||
pending_interrupts: summary.pending_interrupts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubagentUiState {
|
||||
fn is_active(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
SubagentLifecycleStatus::Queued
|
||||
| SubagentLifecycleStatus::Running
|
||||
| SubagentLifecycleStatus::Ready
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Option<UserMessage> {
|
||||
if text.is_empty() && image_paths.is_empty() {
|
||||
None
|
||||
@@ -333,6 +385,12 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
}
|
||||
|
||||
fn default_subagent_label(session_id: ConversationId) -> String {
|
||||
let full = session_id.to_string();
|
||||
let short = full.chars().take(8).collect::<String>();
|
||||
format!("Subagent {short}")
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
fn flush_answer_stream_with_separator(&mut self) {
|
||||
if let Some(mut controller) = self.stream_controller.take()
|
||||
@@ -347,12 +405,225 @@ impl ChatWidget {
|
||||
self.bottom_pane.update_status_header(header);
|
||||
}
|
||||
|
||||
fn on_subagent_lifecycle(&mut self, event: SubagentLifecycleEvent, from_replay: bool) {
|
||||
let Some(parent_id) = self.conversation_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
if from_replay {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
match event {
|
||||
SubagentLifecycleEvent::Created(created) => {
|
||||
if created.subagent.parent_session_id != Some(parent_id) {
|
||||
return;
|
||||
}
|
||||
let state: SubagentUiState = created.subagent.into();
|
||||
self.subagent_states.insert(state.session_id, state);
|
||||
changed = true;
|
||||
}
|
||||
SubagentLifecycleEvent::Status(status) => {
|
||||
if let Some(entry) = self.subagent_states.get_mut(&status.session_id)
|
||||
&& entry.agent_id == status.agent_id
|
||||
&& entry.status != status.status
|
||||
{
|
||||
entry.status = status.status;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
SubagentLifecycleEvent::ReasoningHeader(reasoning) => {
|
||||
if let Some(entry) = self.subagent_states.get_mut(&reasoning.session_id)
|
||||
&& entry.agent_id == reasoning.agent_id
|
||||
{
|
||||
entry.reasoning_header = Some(reasoning.reasoning_header);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
SubagentLifecycleEvent::Deleted(removed) => {
|
||||
let should_remove = self
|
||||
.subagent_states
|
||||
.get(&removed.session_id)
|
||||
.map(|entry| entry.agent_id == removed.agent_id)
|
||||
.unwrap_or(false);
|
||||
if should_remove && self.subagent_states.remove(&removed.session_id).is_some() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.refresh_subagent_visuals();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_agent_inbox(&mut self, event: AgentInboxEvent) {
|
||||
if let Some(entry) = self.subagent_states.get_mut(&event.session_id)
|
||||
&& entry.agent_id == event.agent_id
|
||||
&& (entry.pending_messages != event.pending_messages
|
||||
|| entry.pending_interrupts != event.pending_interrupts)
|
||||
{
|
||||
entry.pending_messages = event.pending_messages;
|
||||
entry.pending_interrupts = event.pending_interrupts;
|
||||
self.refresh_subagent_visuals();
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_subagent_visuals(&mut self) {
|
||||
let (entries, active_count) = self.build_subagent_display_entries();
|
||||
self.bottom_pane.update_subagent_summaries(entries);
|
||||
self.bottom_pane.set_subagent_counts(active_count);
|
||||
}
|
||||
|
||||
fn build_subagent_display_entries(&self) -> (Vec<SubagentDisplayEntry>, usize) {
|
||||
if self.subagent_states.is_empty() {
|
||||
return (Vec::new(), 0);
|
||||
}
|
||||
|
||||
let mut states: Vec<SubagentUiState> = self.subagent_states.values().cloned().collect();
|
||||
states.sort_by(|a, b| {
|
||||
a.started_at_ms
|
||||
.cmp(&b.started_at_ms)
|
||||
.then_with(|| a.session_id.to_string().cmp(&b.session_id.to_string()))
|
||||
});
|
||||
|
||||
let active_count = states.iter().filter(|s| s.is_active()).count();
|
||||
let mut entries: Vec<SubagentDisplayEntry> = Vec::with_capacity(states.len() + 1);
|
||||
let total = states.len();
|
||||
|
||||
let mut detail_parts: Vec<String> = Vec::new();
|
||||
let queued = states
|
||||
.iter()
|
||||
.filter(|s| s.status == SubagentLifecycleStatus::Queued)
|
||||
.count();
|
||||
if queued > 0 {
|
||||
detail_parts.push(format!("{queued} queued"));
|
||||
}
|
||||
let running = states
|
||||
.iter()
|
||||
.filter(|s| s.status == SubagentLifecycleStatus::Running)
|
||||
.count();
|
||||
if running > 0 {
|
||||
detail_parts.push(format!("{running} running"));
|
||||
}
|
||||
let ready = states
|
||||
.iter()
|
||||
.filter(|s| s.status == SubagentLifecycleStatus::Ready)
|
||||
.count();
|
||||
if ready > 0 {
|
||||
detail_parts.push(format!("{ready} ready"));
|
||||
}
|
||||
let idle = states
|
||||
.iter()
|
||||
.filter(|s| s.status == SubagentLifecycleStatus::Idle)
|
||||
.count();
|
||||
if idle > 0 {
|
||||
detail_parts.push(format!("{idle} idle"));
|
||||
}
|
||||
let failed = states
|
||||
.iter()
|
||||
.filter(|s| s.status == SubagentLifecycleStatus::Failed)
|
||||
.count();
|
||||
if failed > 0 {
|
||||
detail_parts.push(format!("{failed} failed"));
|
||||
}
|
||||
let canceled = states
|
||||
.iter()
|
||||
.filter(|s| s.status == SubagentLifecycleStatus::Canceled)
|
||||
.count();
|
||||
if canceled > 0 {
|
||||
detail_parts.push(format!("{canceled} canceled"));
|
||||
}
|
||||
let pending_interrupts_total: usize = states.iter().map(|s| s.pending_interrupts).sum();
|
||||
if pending_interrupts_total > 0 {
|
||||
detail_parts.push(format!(
|
||||
"{pending_interrupts_total} interrupt{}",
|
||||
if pending_interrupts_total == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
));
|
||||
}
|
||||
let pending_messages_total: usize = states.iter().map(|s| s.pending_messages).sum();
|
||||
if pending_messages_total > 0 {
|
||||
detail_parts.push(format!(
|
||||
"{pending_messages_total} pending msg{}",
|
||||
if pending_messages_total == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
|
||||
entries.push(SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Summary,
|
||||
label: format!("{total} subagent{}", if total == 1 { "" } else { "s" }),
|
||||
detail: if detail_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(detail_parts.join(" • "))
|
||||
},
|
||||
status: None,
|
||||
});
|
||||
|
||||
for state in states {
|
||||
let mut label = state.label.clone();
|
||||
if label.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
|
||||
label = Some(default_subagent_label(state.session_id));
|
||||
}
|
||||
let mut detail_segments: Vec<String> = Vec::new();
|
||||
if state.pending_interrupts > 0 {
|
||||
detail_segments.push(format!(
|
||||
"{} interrupt{}",
|
||||
state.pending_interrupts,
|
||||
if state.pending_interrupts == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
));
|
||||
}
|
||||
if state.pending_messages > 0 {
|
||||
detail_segments.push(format!(
|
||||
"{} pending msg{}",
|
||||
state.pending_messages,
|
||||
if state.pending_messages == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if let Some(text) = state
|
||||
.reasoning_header
|
||||
.clone()
|
||||
.or_else(|| state.summary.clone())
|
||||
{
|
||||
detail_segments.push(truncate_text(&text, 160));
|
||||
}
|
||||
let detail = if detail_segments.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(detail_segments.join(" • "))
|
||||
};
|
||||
|
||||
let label_text = label.unwrap_or_else(|| default_subagent_label(state.session_id));
|
||||
let decorated_label = format!("[#{}] {}", state.agent_id, label_text);
|
||||
|
||||
entries.push(SubagentDisplayEntry {
|
||||
kind: SubagentDisplayEntryKind::Item,
|
||||
label: decorated_label,
|
||||
detail,
|
||||
status: Some(state.status),
|
||||
});
|
||||
}
|
||||
|
||||
(entries, active_count)
|
||||
}
|
||||
|
||||
// --- Small event handlers ---
|
||||
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.conversation_id = Some(event.session_id);
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
self.subagent_states.clear();
|
||||
self.refresh_subagent_visuals();
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
let model_for_header = event.model.clone();
|
||||
self.session_header.set_model(&model_for_header);
|
||||
@@ -864,6 +1135,31 @@ impl ChatWidget {
|
||||
self.set_status_header(message);
|
||||
}
|
||||
|
||||
fn on_raw_response_item(&mut self, ev: codex_core::protocol::RawResponseItemEvent) {
|
||||
if let ResponseItem::FunctionCallOutput { output, .. } = ev.item {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(&output.content) {
|
||||
if value
|
||||
.get("injected")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(msg) = value
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|m| m.get("prompt"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let cell =
|
||||
history_cell::new_info_event(format!("subagent await: {msg}"), None);
|
||||
self.add_to_history(cell);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic tick to commit at most one queued line to history with a small delay,
|
||||
/// animating the output.
|
||||
pub(crate) fn on_commit_tick(&mut self) {
|
||||
@@ -938,14 +1234,21 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
|
||||
let running = self.running_commands.remove(&ev.call_id);
|
||||
let (command, parsed, source) = match running {
|
||||
Some(rc) => (rc.command, rc.parsed_cmd, rc.source),
|
||||
let (command, parsed, source, is_user_shell_command) = match running {
|
||||
Some(rc) => (
|
||||
rc.command,
|
||||
rc.parsed_cmd,
|
||||
rc.source,
|
||||
rc.is_user_shell_command,
|
||||
),
|
||||
None => (
|
||||
vec![ev.call_id.clone()],
|
||||
Vec::new(),
|
||||
ExecCommandSource::Agent,
|
||||
false,
|
||||
),
|
||||
};
|
||||
let subagent = parse_subagent_call(&command);
|
||||
let is_unified_exec_interaction =
|
||||
matches!(source, ExecCommandSource::UnifiedExecInteraction);
|
||||
|
||||
@@ -960,9 +1263,11 @@ impl ChatWidget {
|
||||
ev.call_id.clone(),
|
||||
command,
|
||||
parsed,
|
||||
subagent,
|
||||
source,
|
||||
None,
|
||||
self.config.animations,
|
||||
is_user_shell_command,
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1041,12 +1346,15 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
|
||||
// Ensure the status indicator is visible while the command runs.
|
||||
let is_user_shell_command =
|
||||
ev.is_user_shell_command || matches!(ev.source, ExecCommandSource::UserShell);
|
||||
self.running_commands.insert(
|
||||
ev.call_id.clone(),
|
||||
RunningCommand {
|
||||
command: ev.command.clone(),
|
||||
parsed_cmd: ev.parsed_cmd.clone(),
|
||||
source: ev.source,
|
||||
is_user_shell_command,
|
||||
},
|
||||
);
|
||||
let interaction_input = ev.interaction_input.clone();
|
||||
@@ -1060,19 +1368,24 @@ impl ChatWidget {
|
||||
ev.parsed_cmd.clone(),
|
||||
ev.source,
|
||||
interaction_input.clone(),
|
||||
is_user_shell_command,
|
||||
)
|
||||
{
|
||||
*cell = new_exec;
|
||||
} else {
|
||||
self.flush_active_cell();
|
||||
|
||||
let subagent = parse_subagent_call(&ev.command);
|
||||
|
||||
self.active_cell = Some(Box::new(new_active_exec_command(
|
||||
ev.call_id.clone(),
|
||||
ev.command.clone(),
|
||||
ev.parsed_cmd,
|
||||
subagent,
|
||||
ev.source,
|
||||
interaction_input,
|
||||
self.config.animations,
|
||||
is_user_shell_command,
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1179,6 +1492,7 @@ impl ChatWidget {
|
||||
retry_status_header: None,
|
||||
conversation_id: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
subagent_states: HashMap::new(),
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
@@ -1254,6 +1568,7 @@ impl ChatWidget {
|
||||
retry_status_header: None,
|
||||
conversation_id: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
subagent_states: HashMap::new(),
|
||||
show_welcome_banner: true,
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
@@ -1719,8 +2034,18 @@ impl ChatWidget {
|
||||
self.on_entered_review_mode(review_request)
|
||||
}
|
||||
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
|
||||
EventMsg::RawResponseItem(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
EventMsg::SubagentLifecycle(ev) => self.on_subagent_lifecycle(ev, from_replay),
|
||||
EventMsg::AgentInbox(ev) => {
|
||||
if !from_replay {
|
||||
self.on_agent_inbox(ev);
|
||||
}
|
||||
}
|
||||
EventMsg::RawResponseItem(ev) => {
|
||||
if !from_replay {
|
||||
self.on_raw_response_item(ev);
|
||||
}
|
||||
}
|
||||
EventMsg::ItemStarted(_)
|
||||
| EventMsg::ItemCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
|
||||
@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"• Thinking (0s • esc to interrupt) "
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 2660
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"• Thinking (0s • esc to interrupt) "
|
||||
" "
|
||||
|
||||
@@ -20,8 +20,7 @@ expression: term.backend().vt100().screen().contents()
|
||||
↳ Hello, world! 14
|
||||
↳ Hello, world! 15
|
||||
↳ Hello, world! 16
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
↳ Hello, world! 17
|
||||
↳ Hello, world! 18
|
||||
↳ Hello, world! 19
|
||||
↳ Hello, world! 20
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: "combined"
|
||||
---
|
||||
• subagent await: subagent summary: 3 active processes
|
||||
@@ -12,11 +12,14 @@ use codex_core::CodexAuth;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::OPENAI_DEFAULT_MODEL;
|
||||
use codex_core::protocol::AgentInboxEvent;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::AgentReasoningEvent;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::BackgroundEventEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -30,12 +33,21 @@ use codex_core::protocol::Op;
|
||||
use codex_core::protocol::PatchApplyBeginEvent;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::RawResponseItemEvent;
|
||||
use codex_core::protocol::ReviewCodeLocation;
|
||||
use codex_core::protocol::ReviewFinding;
|
||||
use codex_core::protocol::ReviewLineRange;
|
||||
use codex_core::protocol::ReviewOutputEvent;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::SubagentCreatedEvent;
|
||||
use codex_core::protocol::SubagentLifecycleEvent;
|
||||
use codex_core::protocol::SubagentLifecycleOrigin;
|
||||
use codex_core::protocol::SubagentLifecycleStatus;
|
||||
use codex_core::protocol::SubagentReasoningHeaderEvent;
|
||||
use codex_core::protocol::SubagentRemovedEvent;
|
||||
use codex_core::protocol::SubagentStatusEvent;
|
||||
use codex_core::protocol::SubagentSummary;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
use codex_core::protocol::TokenCountEvent;
|
||||
@@ -46,6 +58,8 @@ use codex_core::protocol::UndoStartedEvent;
|
||||
use codex_core::protocol::ViewImageToolCallEvent;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
@@ -56,6 +70,10 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::fs::File;
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
use tempfile::tempdir;
|
||||
@@ -77,6 +95,39 @@ fn test_config() -> Config {
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
// Backward-compat shim for older session logs that predate the
|
||||
// `formatted_output` field on ExecCommandEnd events.
|
||||
fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json::Value {
|
||||
if let Some(obj) = payload.as_object_mut()
|
||||
&& let Some(msg) = obj.get_mut("msg")
|
||||
&& let Some(m) = msg.as_object_mut()
|
||||
{
|
||||
let ty = m.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if ty == "exec_command_end" {
|
||||
let stdout = m.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let stderr = m.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let aggregated = if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}{stderr}")
|
||||
};
|
||||
if !m.contains_key("formatted_output") {
|
||||
m.insert(
|
||||
"formatted_output".to_string(),
|
||||
serde_json::Value::String(aggregated.clone()),
|
||||
);
|
||||
}
|
||||
if !m.contains_key("aggregated_output") {
|
||||
m.insert(
|
||||
"aggregated_output".to_string(),
|
||||
serde_json::Value::String(aggregated),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
@@ -369,6 +420,7 @@ fn make_chatwidget_manual() -> (
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
subagent_states: HashMap::new(),
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
is_review_mode: false,
|
||||
@@ -724,6 +776,7 @@ fn begin_exec_with_source(
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
is_user_shell_command: false,
|
||||
interaction_input,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
@@ -757,6 +810,7 @@ fn end_exec(
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
..
|
||||
} = begin_event;
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.clone(),
|
||||
@@ -787,6 +841,30 @@ fn active_blob(chat: &ChatWidget) -> String {
|
||||
lines_to_single_string(&lines)
|
||||
}
|
||||
|
||||
fn open_fixture(name: &str) -> File {
|
||||
// 1) Prefer fixtures within this crate
|
||||
{
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.push("tests");
|
||||
p.push("fixtures");
|
||||
p.push(name);
|
||||
if let Ok(f) = File::open(&p) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
// 2) Fallback to parent (workspace root)
|
||||
{
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.push("..");
|
||||
p.push(name);
|
||||
if let Ok(f) = File::open(&p) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
// 3) Last resort: CWD
|
||||
File::open(name).expect("open fixture file")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_enter_during_task_does_not_queue() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
@@ -966,6 +1044,261 @@ fn exec_history_cell_shows_working_then_completed() {
|
||||
);
|
||||
}
|
||||
|
||||
struct SummaryBuilder {
|
||||
summary: SubagentSummary,
|
||||
}
|
||||
|
||||
impl SummaryBuilder {
|
||||
fn new(agent_id: u64, parent_session_id: ConversationId, session_id: ConversationId) -> Self {
|
||||
Self {
|
||||
summary: SubagentSummary {
|
||||
agent_id,
|
||||
parent_agent_id: Some(0),
|
||||
session_id,
|
||||
parent_session_id: Some(parent_session_id),
|
||||
origin: SubagentLifecycleOrigin::Spawn,
|
||||
status: SubagentLifecycleStatus::Running,
|
||||
label: None,
|
||||
summary: None,
|
||||
reasoning_header: None,
|
||||
started_at_ms: 0,
|
||||
pending_messages: 0,
|
||||
pending_interrupts: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn origin(mut self, origin: SubagentLifecycleOrigin) -> Self {
|
||||
self.summary.origin = origin;
|
||||
self
|
||||
}
|
||||
|
||||
fn status(mut self, status: SubagentLifecycleStatus) -> Self {
|
||||
self.summary.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
fn label(mut self, label: impl Into<String>) -> Self {
|
||||
self.summary.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn summary_text(mut self, summary: impl Into<String>) -> Self {
|
||||
self.summary.summary = Some(summary.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn reasoning_header(mut self, header: impl Into<String>) -> Self {
|
||||
self.summary.reasoning_header = Some(header.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn started_at(mut self, started_at_ms: i64) -> Self {
|
||||
self.summary.started_at_ms = started_at_ms;
|
||||
self
|
||||
}
|
||||
|
||||
fn pending(mut self, pending_messages: usize, pending_interrupts: usize) -> Self {
|
||||
self.summary.pending_messages = pending_messages;
|
||||
self.summary.pending_interrupts = pending_interrupts;
|
||||
self
|
||||
}
|
||||
|
||||
fn build(self) -> SubagentSummary {
|
||||
self.summary
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_created(
|
||||
chat: &mut ChatWidget,
|
||||
id: Option<&str>,
|
||||
summary: SubagentSummary,
|
||||
replayed: bool,
|
||||
) {
|
||||
chat.dispatch_event_msg(
|
||||
id.map(str::to_string),
|
||||
EventMsg::SubagentLifecycle(SubagentLifecycleEvent::Created(SubagentCreatedEvent {
|
||||
subagent: summary,
|
||||
})),
|
||||
replayed,
|
||||
);
|
||||
}
|
||||
|
||||
fn dispatch_reasoning_header(
|
||||
chat: &mut ChatWidget,
|
||||
id: Option<&str>,
|
||||
agent_id: u64,
|
||||
session_id: ConversationId,
|
||||
header: impl Into<String>,
|
||||
replayed: bool,
|
||||
) {
|
||||
chat.dispatch_event_msg(
|
||||
id.map(str::to_string),
|
||||
EventMsg::SubagentLifecycle(SubagentLifecycleEvent::ReasoningHeader(
|
||||
SubagentReasoningHeaderEvent {
|
||||
agent_id,
|
||||
session_id,
|
||||
reasoning_header: header.into(),
|
||||
},
|
||||
)),
|
||||
replayed,
|
||||
);
|
||||
}
|
||||
|
||||
fn dispatch_status(
|
||||
chat: &mut ChatWidget,
|
||||
id: Option<&str>,
|
||||
agent_id: u64,
|
||||
session_id: ConversationId,
|
||||
status: SubagentLifecycleStatus,
|
||||
replayed: bool,
|
||||
) {
|
||||
chat.dispatch_event_msg(
|
||||
id.map(str::to_string),
|
||||
EventMsg::SubagentLifecycle(SubagentLifecycleEvent::Status(SubagentStatusEvent {
|
||||
agent_id,
|
||||
session_id,
|
||||
status,
|
||||
})),
|
||||
replayed,
|
||||
);
|
||||
}
|
||||
|
||||
fn dispatch_deleted(
|
||||
chat: &mut ChatWidget,
|
||||
id: Option<&str>,
|
||||
agent_id: u64,
|
||||
session_id: ConversationId,
|
||||
replayed: bool,
|
||||
) {
|
||||
chat.dispatch_event_msg(
|
||||
id.map(str::to_string),
|
||||
EventMsg::SubagentLifecycle(SubagentLifecycleEvent::Deleted(SubagentRemovedEvent {
|
||||
agent_id,
|
||||
session_id,
|
||||
})),
|
||||
replayed,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_lifecycle_updates_bottom_pane() {
|
||||
let (mut chat, mut _rx, _ops) = make_chatwidget_manual();
|
||||
let parent_id = ConversationId::new();
|
||||
chat.conversation_id = Some(parent_id);
|
||||
chat.on_task_started();
|
||||
|
||||
let child_id = ConversationId::new();
|
||||
let summary = SummaryBuilder::new(1, parent_id, child_id)
|
||||
.origin(SubagentLifecycleOrigin::Fork)
|
||||
.status(SubagentLifecycleStatus::Running)
|
||||
.label("Docs sweep")
|
||||
.summary_text("Rewrite md files")
|
||||
.started_at(42)
|
||||
.build();
|
||||
|
||||
dispatch_created(&mut chat, Some("created"), summary, false);
|
||||
|
||||
assert_eq!(chat.bottom_pane.subagent_count(), 1);
|
||||
assert_eq!(chat.bottom_pane.subagent_entries().len(), 2);
|
||||
|
||||
dispatch_reasoning_header(
|
||||
&mut chat,
|
||||
Some("header"),
|
||||
1,
|
||||
child_id,
|
||||
"Scanning tests",
|
||||
false,
|
||||
);
|
||||
let entries = chat.bottom_pane.subagent_entries();
|
||||
assert_eq!(entries[1].detail.as_deref(), Some("Scanning tests"));
|
||||
|
||||
dispatch_status(
|
||||
&mut chat,
|
||||
Some("status"),
|
||||
1,
|
||||
child_id,
|
||||
SubagentLifecycleStatus::Idle,
|
||||
false,
|
||||
);
|
||||
assert_eq!(chat.bottom_pane.subagent_count(), 0);
|
||||
let entries = chat.bottom_pane.subagent_entries();
|
||||
assert_eq!(entries[1].detail.as_deref(), Some("Scanning tests"));
|
||||
|
||||
dispatch_deleted(&mut chat, Some("deleted"), 1, child_id, false);
|
||||
assert!(chat.bottom_pane.subagent_entries().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replayed_subagent_events_are_ignored() {
|
||||
let (mut chat, mut _rx, _ops) = make_chatwidget_manual();
|
||||
let parent_id = ConversationId::new();
|
||||
chat.conversation_id = Some(parent_id);
|
||||
|
||||
let child_id = ConversationId::new();
|
||||
let summary = SummaryBuilder::new(1, parent_id, child_id)
|
||||
.origin(SubagentLifecycleOrigin::Spawn)
|
||||
.status(SubagentLifecycleStatus::Running)
|
||||
.started_at(100)
|
||||
.build();
|
||||
|
||||
dispatch_created(&mut chat, None, summary, true);
|
||||
|
||||
assert!(chat.bottom_pane.subagent_entries().is_empty());
|
||||
assert_eq!(chat.bottom_pane.subagent_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_inbox_updates_pending_counts() {
|
||||
let (mut chat, mut _rx, _ops) = make_chatwidget_manual();
|
||||
let parent_id = ConversationId::new();
|
||||
chat.conversation_id = Some(parent_id);
|
||||
|
||||
let child_id = ConversationId::new();
|
||||
let summary = SummaryBuilder::new(1, parent_id, child_id)
|
||||
.origin(SubagentLifecycleOrigin::Spawn)
|
||||
.status(SubagentLifecycleStatus::Running)
|
||||
.label("Docs sweep")
|
||||
.started_at(100)
|
||||
.build();
|
||||
|
||||
dispatch_created(&mut chat, None, summary, false);
|
||||
|
||||
chat.dispatch_event_msg(
|
||||
None,
|
||||
EventMsg::AgentInbox(AgentInboxEvent {
|
||||
agent_id: 1,
|
||||
session_id: child_id,
|
||||
pending_messages: 2,
|
||||
pending_interrupts: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
let entries = chat.bottom_pane.subagent_entries();
|
||||
assert_eq!(entries.len(), 2);
|
||||
let summary_entry = &entries[0];
|
||||
let detail = summary_entry.detail.as_deref().expect("summary detail");
|
||||
assert!(
|
||||
detail.contains("1 interrupt"),
|
||||
"summary should include interrupt count: {detail}"
|
||||
);
|
||||
assert!(
|
||||
detail.contains("2 pending msgs"),
|
||||
"summary should include pending message count: {detail}"
|
||||
);
|
||||
let agent_entry = &entries[1];
|
||||
let detail = agent_entry.detail.as_deref().expect("agent detail");
|
||||
assert!(
|
||||
detail.contains("1 interrupt"),
|
||||
"agent detail should include interrupt count: {detail}"
|
||||
);
|
||||
assert!(
|
||||
detail.contains("2 pending msgs"),
|
||||
"agent detail should include pending msg count: {detail}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_cell_shows_working_then_failed() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
@@ -1016,6 +1349,59 @@ fn exec_history_shows_unified_exec_startup_commands() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_tool_calls_render_as_ran_entries() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
let call_id = "sub-call-1";
|
||||
let command = vec!["subagent_spawn".to_string(), "label=docs".to_string()];
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: Vec::new(),
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
interaction_input: None,
|
||||
}),
|
||||
});
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd: Vec::new(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: "spawned agent #2".to_string(),
|
||||
exit_code: 0,
|
||||
duration: std::time::Duration::from_millis(8),
|
||||
formatted_output: "spawned agent #2".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected a single exec history cell");
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
rendered.contains("Ran subagent_spawn"),
|
||||
"expected command header in history cell: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("spawned agent #2"),
|
||||
"expected tool output to be rendered: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Selecting the custom prompt option from the review popup sends
|
||||
/// OpenReviewCustomPrompt to the app event channel.
|
||||
#[test]
|
||||
@@ -1353,6 +1739,39 @@ fn view_image_tool_call_adds_history_cell() {
|
||||
assert_snapshot!("local_image_attachment_history_snapshot", combined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_summary_history_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
let payload = json!({
|
||||
"injected": true,
|
||||
"messages": [
|
||||
{ "prompt": "subagent summary: 3 active processes" }
|
||||
]
|
||||
});
|
||||
let event = RawResponseItemEvent {
|
||||
item: ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-subagent-summary".into(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: payload.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: "raw-subagent-summary".into(),
|
||||
msg: EventMsg::RawResponseItem(event),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
1,
|
||||
"expected a single history cell for the injected subagent summary"
|
||||
);
|
||||
let combined = lines_to_single_string(&cells[0]);
|
||||
assert_snapshot!("subagent_summary_history_snapshot", combined);
|
||||
}
|
||||
|
||||
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
|
||||
// marker (replacing the spinner) and flushes it into history.
|
||||
#[test]
|
||||
@@ -1832,6 +2251,168 @@ fn disabled_slash_command_while_task_running_snapshot() {
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert_snapshot!(blob);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn binary_size_transcript_snapshot() {
|
||||
// the snapshot in this test depends on gpt-5-codex. Skip for now. We will consider
|
||||
// creating snapshots for other models in the future.
|
||||
if OPENAI_DEFAULT_MODEL != "gpt-5-codex" {
|
||||
return;
|
||||
}
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 2000;
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
|
||||
.expect("failed to construct terminal");
|
||||
terminal.set_viewport_area(viewport);
|
||||
|
||||
// Replay the recorded session into the widget and collect transcript
|
||||
let file = open_fixture("binary-size-log.jsonl");
|
||||
let reader = BufReader::new(file);
|
||||
let mut transcript = String::new();
|
||||
let mut has_emitted_history = false;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line.expect("read line");
|
||||
if line.trim().is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Ok(v): Result<serde_json::Value, _> = serde_json::from_str(&line) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dir) = v.get("dir").and_then(|d| d.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if dir != "to_tui" {
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = v.get("kind").and_then(|k| k.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match kind {
|
||||
"codex_event" => {
|
||||
if let Some(payload) = v.get("payload") {
|
||||
let ev: Event =
|
||||
serde_json::from_value(upgrade_event_payload_for_tests(payload.clone()))
|
||||
.expect("parse");
|
||||
let ev = match ev {
|
||||
Event {
|
||||
msg: EventMsg::ExecCommandBegin(e),
|
||||
..
|
||||
} => {
|
||||
// Re-parse the command
|
||||
let parsed_cmd = codex_core::parse_command::parse_command(&e.command);
|
||||
Event {
|
||||
id: ev.id,
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: e.call_id.clone(),
|
||||
turn_id: e.turn_id.clone(),
|
||||
command: e.command,
|
||||
cwd: e.cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: e.is_user_shell_command,
|
||||
interaction_input: e.interaction_input.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
_ => ev,
|
||||
};
|
||||
chat.handle_codex_event(ev);
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = app_ev {
|
||||
let mut lines = cell.display_lines(width);
|
||||
if has_emitted_history
|
||||
&& !cell.is_stream_continuation()
|
||||
&& !lines.is_empty()
|
||||
{
|
||||
lines.insert(0, "".into());
|
||||
}
|
||||
has_emitted_history = true;
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines(&mut terminal, lines)
|
||||
.expect("Failed to insert history lines in test");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"app_event" => {
|
||||
if let Some(variant) = v.get("variant").and_then(|s| s.as_str())
|
||||
&& variant == "CommitTick"
|
||||
{
|
||||
chat.on_commit_tick();
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = app_ev {
|
||||
let mut lines = cell.display_lines(width);
|
||||
if has_emitted_history
|
||||
&& !cell.is_stream_continuation()
|
||||
&& !lines.is_empty()
|
||||
{
|
||||
lines.insert(0, "".into());
|
||||
}
|
||||
has_emitted_history = true;
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines(&mut terminal, lines)
|
||||
.expect("Failed to insert history lines in test");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
|
||||
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
|
||||
let screen = terminal.backend().vt100().screen();
|
||||
let mut lines: Vec<String> = Vec::with_capacity(height as usize);
|
||||
for row in 0..height {
|
||||
let mut s = String::with_capacity(width as usize);
|
||||
for col in 0..width {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
if let Some(ch) = cell.contents().chars().next() {
|
||||
s.push(ch);
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
// Trim trailing spaces to match plain text fixture
|
||||
lines.push(s.trim_end().to_string());
|
||||
}
|
||||
while lines.last().is_some_and(std::string::String::is_empty) {
|
||||
lines.pop();
|
||||
}
|
||||
// Consider content only after the last session banner marker. Skip the transient
|
||||
// 'thinking' header if present, and start from the first non-empty content line
|
||||
// that follows. This keeps the snapshot stable across sessions.
|
||||
const MARKER_PREFIX: &str = "To get started, describe a task or try one of these commands:";
|
||||
let last_marker_line_idx = lines
|
||||
.iter()
|
||||
.rposition(|l| l.trim_start().starts_with(MARKER_PREFIX))
|
||||
.expect("marker not found in visible output");
|
||||
// Prefer the first assistant content line (blockquote '>' prefix) after the marker;
|
||||
// fallback to the first non-empty, non-'thinking' line.
|
||||
let start_idx = (last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| lines[idx].trim_start().starts_with('•'))
|
||||
.unwrap_or_else(|| {
|
||||
(last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| {
|
||||
let t = lines[idx].trim_start();
|
||||
!t.is_empty() && t != "thinking"
|
||||
})
|
||||
.expect("no content line found after marker")
|
||||
});
|
||||
|
||||
// Snapshot the normalized visible transcript following the banner.
|
||||
assert_snapshot!("binary_size_ideal_response", lines[start_idx..].join("\n"));
|
||||
}
|
||||
|
||||
//
|
||||
// Snapshot test: command approval modal
|
||||
@@ -2859,6 +3440,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
interaction_input: None,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
mod model;
|
||||
mod render;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use model::CommandOutput;
|
||||
#[cfg(test)]
|
||||
pub(crate) use model::ExecCall;
|
||||
pub(crate) use model::ExecCell;
|
||||
#[cfg(test)]
|
||||
pub(crate) use model::SubagentCell;
|
||||
pub(crate) use model::parse_subagent_call;
|
||||
pub(crate) use render::OutputLinesParams;
|
||||
pub(crate) use render::TOOL_CALL_MAX_LINES;
|
||||
pub(crate) use render::new_active_exec_command;
|
||||
|
||||
@@ -3,6 +3,138 @@ use std::time::Instant;
|
||||
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(tag = "kind")]
|
||||
pub(crate) enum SubagentCell {
|
||||
Spawn {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
},
|
||||
Fork {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
},
|
||||
SendMessage {
|
||||
label: String,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
},
|
||||
List {
|
||||
#[serde(default)]
|
||||
count: Option<usize>,
|
||||
},
|
||||
Await {
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default)]
|
||||
timed_out: Option<bool>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
lifecycle_status: Option<String>,
|
||||
},
|
||||
Cancel {
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
},
|
||||
Prune {
|
||||
#[serde(default)]
|
||||
counts: Option<serde_json::Value>,
|
||||
},
|
||||
Logs {
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default)]
|
||||
rendered: Option<String>,
|
||||
},
|
||||
Watchdog {
|
||||
#[serde(default)]
|
||||
action: Option<String>,
|
||||
#[serde(default)]
|
||||
interval_s: Option<u64>,
|
||||
#[serde(default)]
|
||||
message: Option<String>,
|
||||
},
|
||||
Raw {
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn parse_subagent_call(command: &[String]) -> Option<SubagentCell> {
|
||||
let first = command.first()?;
|
||||
let trimmed = first.trim();
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("Forked subagent") {
|
||||
return Some(SubagentCell::Fork {
|
||||
label: rest.trim().to_string(),
|
||||
model: None,
|
||||
summary: None,
|
||||
});
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("Spawned subagent") {
|
||||
return Some(SubagentCell::Spawn {
|
||||
label: rest.trim().to_string(),
|
||||
model: None,
|
||||
summary: None,
|
||||
});
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("Sent message to subagent") {
|
||||
return Some(SubagentCell::SendMessage {
|
||||
label: rest.trim().to_string(),
|
||||
summary: None,
|
||||
});
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("Awaited subagent") {
|
||||
return Some(SubagentCell::Await {
|
||||
label: Some(rest.trim().to_string()),
|
||||
timed_out: None,
|
||||
message: None,
|
||||
lifecycle_status: None,
|
||||
});
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("Canceled subagent") {
|
||||
return Some(SubagentCell::Cancel {
|
||||
label: Some(rest.trim().to_string()),
|
||||
});
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("Fetched subagent logs") {
|
||||
return Some(SubagentCell::Logs {
|
||||
label: Some(rest.trim().to_string()),
|
||||
rendered: None,
|
||||
});
|
||||
}
|
||||
if trimmed.starts_with("Pruned subagents") {
|
||||
return Some(SubagentCell::Prune { counts: None });
|
||||
}
|
||||
if trimmed.starts_with("Listed subagents") {
|
||||
return Some(SubagentCell::List { count: None });
|
||||
}
|
||||
if trimmed.starts_with("Started watchdog") || trimmed.starts_with("Replaced watchdog") {
|
||||
return Some(SubagentCell::Watchdog {
|
||||
action: None,
|
||||
interval_s: None,
|
||||
message: None,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn subagent_from_formatted_output(formatted_output: &str) -> Option<SubagentCell> {
|
||||
serde_json::from_str::<serde_json::Value>(formatted_output)
|
||||
.ok()
|
||||
.and_then(|v| v.get("subagent_render").cloned())
|
||||
.and_then(|v| serde_json::from_value::<SubagentCell>(v).ok())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct CommandOutput {
|
||||
@@ -18,8 +150,10 @@ pub(crate) struct ExecCall {
|
||||
pub(crate) call_id: String,
|
||||
pub(crate) command: Vec<String>,
|
||||
pub(crate) parsed: Vec<ParsedCommand>,
|
||||
pub(crate) subagent: Option<SubagentCell>,
|
||||
pub(crate) output: Option<CommandOutput>,
|
||||
pub(crate) source: ExecCommandSource,
|
||||
pub(crate) is_user_shell_command: bool,
|
||||
pub(crate) start_time: Option<Instant>,
|
||||
pub(crate) duration: Option<Duration>,
|
||||
pub(crate) interaction_input: Option<String>,
|
||||
@@ -46,13 +180,17 @@ impl ExecCell {
|
||||
parsed: Vec<ParsedCommand>,
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
is_user_shell_command: bool,
|
||||
) -> Option<Self> {
|
||||
let subagent = parse_subagent_call(&command);
|
||||
let call = ExecCall {
|
||||
call_id,
|
||||
command,
|
||||
parsed,
|
||||
subagent,
|
||||
output: None,
|
||||
source,
|
||||
is_user_shell_command,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input,
|
||||
@@ -74,6 +212,9 @@ impl ExecCell {
|
||||
duration: Duration,
|
||||
) {
|
||||
if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) {
|
||||
if let Some(subagent) = subagent_from_formatted_output(&output.formatted_output) {
|
||||
call.subagent = Some(subagent);
|
||||
}
|
||||
call.output = Some(output);
|
||||
call.duration = Some(duration);
|
||||
call.start_time = None;
|
||||
@@ -106,6 +247,12 @@ impl ExecCell {
|
||||
self.calls.iter().all(Self::is_exploring_call)
|
||||
}
|
||||
|
||||
pub(crate) fn is_subagent_cell(&self) -> bool {
|
||||
self.calls
|
||||
.iter()
|
||||
.all(|c| Self::is_subagent_call(c) && !c.is_user_shell_command)
|
||||
}
|
||||
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.calls.iter().any(|c| c.output.is_none())
|
||||
}
|
||||
@@ -137,11 +284,15 @@ impl ExecCell {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is_subagent_call(call: &ExecCall) -> bool {
|
||||
call.subagent.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCall {
|
||||
pub(crate) fn is_user_shell_command(&self) -> bool {
|
||||
matches!(self.source, ExecCommandSource::UserShell)
|
||||
self.is_user_shell_command
|
||||
}
|
||||
|
||||
pub(crate) fn is_unified_exec_interaction(&self) -> bool {
|
||||
|
||||
@@ -3,6 +3,9 @@ use std::time::Instant;
|
||||
use super::model::CommandOutput;
|
||||
use super::model::ExecCall;
|
||||
use super::model::ExecCell;
|
||||
use super::model::SubagentCell;
|
||||
use super::model::parse_subagent_call;
|
||||
use super::model::subagent_from_formatted_output;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
@@ -27,6 +30,32 @@ pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
|
||||
const MAX_INTERACTION_PREVIEW_CHARS: usize = 80;
|
||||
|
||||
fn header_parts(cell: &SubagentCell) -> Option<(String, Option<String>)> {
|
||||
match cell {
|
||||
SubagentCell::Fork { label, .. } => Some(("Forked subagent".into(), Some(label.clone()))),
|
||||
SubagentCell::Spawn { label, .. } => Some(("Spawned subagent".into(), Some(label.clone()))),
|
||||
SubagentCell::SendMessage { label, .. } => {
|
||||
Some(("Sent message to subagent".into(), Some(label.clone())))
|
||||
}
|
||||
SubagentCell::Await { label, .. } => Some(("Awaited subagent".into(), label.clone())),
|
||||
SubagentCell::Prune { .. } => Some(("Pruned subagents".into(), None)),
|
||||
SubagentCell::Logs { label, .. } => Some((
|
||||
"Fetched subagent logs".into(),
|
||||
label.as_ref().map(|l| format!("from {l}")),
|
||||
)),
|
||||
SubagentCell::List { .. } => Some(("Listed subagents".into(), None)),
|
||||
SubagentCell::Watchdog { action, .. } => {
|
||||
Some(("Watchdog".into(), action.as_ref().map(|a| a.to_string())))
|
||||
}
|
||||
SubagentCell::Cancel { label } => Some(("Canceled subagent".into(), label.clone())),
|
||||
SubagentCell::Raw { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn subagent_header_parts(command: &[String]) -> Option<(String, Option<String>)> {
|
||||
parse_subagent_call(command).as_ref().and_then(header_parts)
|
||||
}
|
||||
|
||||
pub(crate) struct OutputLinesParams {
|
||||
pub(crate) line_limit: usize,
|
||||
pub(crate) only_err: bool,
|
||||
@@ -38,17 +67,22 @@ pub(crate) fn new_active_exec_command(
|
||||
call_id: String,
|
||||
command: Vec<String>,
|
||||
parsed: Vec<ParsedCommand>,
|
||||
subagent: Option<SubagentCell>,
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
animations_enabled: bool,
|
||||
is_user_shell_command: bool,
|
||||
) -> ExecCell {
|
||||
let subagent = subagent.or_else(|| parse_subagent_call(&command));
|
||||
ExecCell::new(
|
||||
ExecCall {
|
||||
call_id,
|
||||
command,
|
||||
parsed,
|
||||
subagent,
|
||||
output: None,
|
||||
source,
|
||||
is_user_shell_command,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input,
|
||||
@@ -192,6 +226,8 @@ impl HistoryCell for ExecCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.is_exploring_cell() {
|
||||
self.exploring_display_lines(width)
|
||||
} else if self.is_subagent_cell() {
|
||||
self.subagent_display_lines(width)
|
||||
} else {
|
||||
self.command_display_lines(width)
|
||||
}
|
||||
@@ -345,6 +381,143 @@ impl ExecCell {
|
||||
out
|
||||
}
|
||||
|
||||
fn subagent_display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
let [call] = &self.calls.as_slice() else {
|
||||
panic!("Expected exactly one call in a subagent display cell");
|
||||
};
|
||||
let bullet = if let Some(output) = &call.output {
|
||||
if output.exit_code == 0 {
|
||||
"•".green().bold()
|
||||
} else {
|
||||
"•".red().bold()
|
||||
}
|
||||
} else {
|
||||
spinner(call.start_time, self.animations_enabled())
|
||||
};
|
||||
|
||||
let render = call
|
||||
.output
|
||||
.as_ref()
|
||||
.and_then(|o| subagent_from_formatted_output(&o.formatted_output))
|
||||
.or_else(|| call.subagent.clone())
|
||||
.or_else(|| {
|
||||
subagent_header_parts(&call.command).map(|(verb, rest)| SubagentCell::Raw {
|
||||
text: format!(
|
||||
"{}{}",
|
||||
verb,
|
||||
rest.map(|r| format!(" {r}")).unwrap_or_default()
|
||||
),
|
||||
})
|
||||
});
|
||||
|
||||
let Some((verb, rest)) = render.as_ref().and_then(header_parts) else {
|
||||
return vec![Line::from(vec![bullet, " ".into(), "Subagent".bold()])];
|
||||
};
|
||||
|
||||
let mut header: Vec<Span<'static>> = vec![bullet, " ".into(), verb.bold()];
|
||||
if let Some(rest) = rest
|
||||
&& !rest.is_empty()
|
||||
{
|
||||
header.push(" ".into());
|
||||
header.push(rest.into());
|
||||
}
|
||||
|
||||
let mut lines = vec![Line::from(header)];
|
||||
|
||||
if let Some(render) = render {
|
||||
let mut body_lines: Vec<Line<'static>> = Vec::new();
|
||||
match render {
|
||||
SubagentCell::Spawn { model, summary, .. }
|
||||
| SubagentCell::Fork { model, summary, .. } => {
|
||||
if let Some(m) = model {
|
||||
body_lines.push(Line::from(vec![format!("model={m}").into()]));
|
||||
}
|
||||
if let Some(s) = summary {
|
||||
body_lines.push(Line::from(vec![format!("\"{s}\"").into()]));
|
||||
}
|
||||
}
|
||||
SubagentCell::SendMessage { summary, .. } => {
|
||||
if let Some(s) = summary {
|
||||
body_lines.push(Line::from(vec![format!("\"{s}\"").into()]));
|
||||
}
|
||||
}
|
||||
SubagentCell::List { count } => {
|
||||
if let Some(count) = count {
|
||||
body_lines
|
||||
.push(Line::from(vec![format!("listed {count} subagents").into()]));
|
||||
}
|
||||
}
|
||||
SubagentCell::Await {
|
||||
timed_out,
|
||||
message,
|
||||
lifecycle_status,
|
||||
..
|
||||
} => {
|
||||
if timed_out.unwrap_or(false) {
|
||||
body_lines.push(Line::from("Timed out"));
|
||||
} else if let Some(msg) = message {
|
||||
body_lines.push(Line::from(msg));
|
||||
} else if let Some(status) = lifecycle_status {
|
||||
body_lines.push(Line::from(vec![format!("status={status}").into()]));
|
||||
}
|
||||
}
|
||||
SubagentCell::Cancel { .. } => {}
|
||||
SubagentCell::Prune { counts } => {
|
||||
if let Some(counts) = counts {
|
||||
body_lines.push(Line::from(vec![
|
||||
format!("pruned subagents {counts}").into(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
SubagentCell::Logs { rendered, .. } => {
|
||||
if let Some(rendered) = rendered {
|
||||
for l in rendered.lines() {
|
||||
body_lines.push(Line::from(l.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
SubagentCell::Watchdog {
|
||||
action,
|
||||
interval_s,
|
||||
message,
|
||||
} => {
|
||||
if let Some(a) = action {
|
||||
body_lines.push(Line::from(vec![format!("action={a}").into()]));
|
||||
}
|
||||
if let Some(i) = interval_s {
|
||||
body_lines.push(Line::from(vec![format!("interval={i}s").into()]));
|
||||
}
|
||||
if let Some(msg) = message {
|
||||
body_lines.push(Line::from(vec![format!("\"{msg}\"").into()]));
|
||||
}
|
||||
}
|
||||
SubagentCell::Raw { text } => {
|
||||
body_lines.push(Line::from(text));
|
||||
}
|
||||
}
|
||||
|
||||
if !body_lines.is_empty() {
|
||||
lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into()));
|
||||
}
|
||||
} else if let Some(output) = call.output.as_ref() {
|
||||
let OutputLines {
|
||||
lines: out_lines, ..
|
||||
} = output_lines(
|
||||
Some(output),
|
||||
OutputLinesParams {
|
||||
line_limit: USER_SHELL_TOOL_CALL_MAX_LINES,
|
||||
only_err: false,
|
||||
include_angle_pipe: false,
|
||||
include_prefix: false,
|
||||
},
|
||||
);
|
||||
let prefixed = prefix_lines(out_lines, " └ ".dim(), " ".into());
|
||||
lines.extend(prefixed);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let [call] = &self.calls.as_slice() else {
|
||||
panic!("Expected exactly one call in a command display cell");
|
||||
|
||||
204
codex-rs/tui/src/exec_cell/tests.rs
Normal file
204
codex-rs/tui/src/exec_cell/tests.rs
Normal file
@@ -0,0 +1,204 @@
|
||||
use super::*;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn render(cell: &ExecCell) -> String {
|
||||
cell.display_lines(120)
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| s.content.to_string())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn make_cell(header: &str, render: SubagentCell, exit_code: i32) -> ExecCell {
|
||||
let command = vec![header.to_string()];
|
||||
let call = ExecCall {
|
||||
call_id: "c1".to_string(),
|
||||
subagent: Some(render),
|
||||
command,
|
||||
parsed: vec![],
|
||||
output: Some(CommandOutput {
|
||||
exit_code,
|
||||
aggregated_output: String::new(),
|
||||
formatted_output: String::new(),
|
||||
}),
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: None,
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
};
|
||||
ExecCell::new(call, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_spawn_with_model() {
|
||||
let cell = make_cell(
|
||||
"Spawned subagent exec-review",
|
||||
SubagentCell::Spawn {
|
||||
label: "exec-review".into(),
|
||||
model: Some("gpt-5.1-codex-mini".into()),
|
||||
summary: Some("Review exec changes".into()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Spawned subagent exec-review
|
||||
└ model=gpt-5.1-codex-mini
|
||||
"Review exec changes"
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_fork_prompt_only() {
|
||||
let cell = make_cell(
|
||||
"Forked subagent tui-review",
|
||||
SubagentCell::Fork {
|
||||
label: "tui-review".into(),
|
||||
model: None,
|
||||
summary: Some("Review TUI widgets".into()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Forked subagent tui-review
|
||||
└ "Review TUI widgets"
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_send_message() {
|
||||
let cell = make_cell(
|
||||
"Sent message to subagent tui-review",
|
||||
SubagentCell::SendMessage {
|
||||
label: "tui-review".into(),
|
||||
summary: Some("Remember stylize rules".into()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Sent message to subagent tui-review
|
||||
└ "Remember stylize rules"
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_await_complete() {
|
||||
let cell = make_cell(
|
||||
"Awaited subagent core-review",
|
||||
SubagentCell::Await {
|
||||
label: Some("core-review".into()),
|
||||
timed_out: Some(false),
|
||||
message: Some("Completed: \"finding summary\"".into()),
|
||||
lifecycle_status: Some("completed".into()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Awaited subagent core-review
|
||||
└ Completed: "finding summary"
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_await_timeout() {
|
||||
let cell = make_cell(
|
||||
"Awaited subagent core-review",
|
||||
SubagentCell::Await {
|
||||
label: Some("core-review".into()),
|
||||
timed_out: Some(true),
|
||||
message: None,
|
||||
lifecycle_status: Some("running".into()),
|
||||
},
|
||||
1,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Awaited subagent core-review
|
||||
└ Timed out
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_logs() {
|
||||
let rendered = "Session s • status=idle • older_logs=false • at_latest=true\n2025-11-11T01:06:53.424Z Thinking: ** (1 delta)\n2025-11-11T01:06:53.442Z Reasoning summary: **Evaluating safe shell command execution**";
|
||||
let cell = make_cell(
|
||||
"Fetched subagent logs from core-review",
|
||||
SubagentCell::Logs {
|
||||
label: Some("core-review".into()),
|
||||
rendered: Some(rendered.into()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Fetched subagent logs from core-review
|
||||
└ Session s • status=idle • older_logs=false • at_latest=true
|
||||
2025-11-11T01:06:53.424Z Thinking: ** (1 delta)
|
||||
2025-11-11T01:06:53.442Z Reasoning summary: **Evaluating safe shell command execution**
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_cancel() {
|
||||
let cell = make_cell(
|
||||
"Canceled subagent core-review",
|
||||
SubagentCell::Cancel {
|
||||
label: Some("core-review".into()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"• Canceled subagent core-review"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_list_subagents() {
|
||||
let cell = make_cell("Listed subagents", SubagentCell::List { count: Some(7) }, 0);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Listed subagents
|
||||
└ listed 7 subagents
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_list_subagents_without_count() {
|
||||
let cell = make_cell("Listed subagents", SubagentCell::List { count: None }, 0);
|
||||
assert_snapshot!(render(&cell), @r###"• Listed subagents"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_watchdog_summary() {
|
||||
let cell = make_cell(
|
||||
"Watchdog replaced interval",
|
||||
SubagentCell::Watchdog {
|
||||
action: Some("Replaced".into()),
|
||||
interval_s: Some(30),
|
||||
message: Some("Heartbeat guard".into()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"
|
||||
• Watchdog Replaced
|
||||
└ action=Replaced
|
||||
interval=30s
|
||||
"Heartbeat guard"
|
||||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_watchdog_missing_fields() {
|
||||
let cell = make_cell(
|
||||
"Watchdog replaced interval",
|
||||
SubagentCell::Watchdog {
|
||||
action: None,
|
||||
interval_s: None,
|
||||
message: None,
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_snapshot!(render(&cell), @r###"• Watchdog"###);
|
||||
}
|
||||
@@ -708,7 +708,7 @@ impl SessionHeaderHistoryCell {
|
||||
ReasoningEffortConfig::Low => "low",
|
||||
ReasoningEffortConfig::Medium => "medium",
|
||||
ReasoningEffortConfig::High => "high",
|
||||
ReasoningEffortConfig::XHigh => "xhigh",
|
||||
ReasoningEffortConfig::XHigh => "x-high",
|
||||
ReasoningEffortConfig::None => "none",
|
||||
})
|
||||
}
|
||||
@@ -1860,10 +1860,12 @@ mod tests {
|
||||
fn coalesces_sequential_reads_within_one_call() {
|
||||
// Build one exec cell with a Search followed by two Reads
|
||||
let call_id = "c1".to_string();
|
||||
let command = vec!["bash".into(), "-lc".into(), "echo".into()];
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo".into()],
|
||||
subagent: None,
|
||||
command,
|
||||
parsed: vec![
|
||||
ParsedCommand::Search {
|
||||
query: Some("shimmer_spans".into()),
|
||||
@@ -1883,6 +1885,7 @@ mod tests {
|
||||
],
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -1908,8 +1911,10 @@ mod tests {
|
||||
path: None,
|
||||
cmd: "rg shimmer_spans".into(),
|
||||
}],
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -1930,6 +1935,7 @@ mod tests {
|
||||
}],
|
||||
ExecCommandSource::Agent,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
cell.complete_call("c2", CommandOutput::default(), Duration::from_millis(1));
|
||||
@@ -1945,6 +1951,7 @@ mod tests {
|
||||
}],
|
||||
ExecCommandSource::Agent,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
cell.complete_call("c3", CommandOutput::default(), Duration::from_millis(1));
|
||||
@@ -1977,8 +1984,10 @@ mod tests {
|
||||
path: "shimmer.rs".into(),
|
||||
},
|
||||
],
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -2001,8 +2010,10 @@ mod tests {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), cmd],
|
||||
parsed: Vec::new(),
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -2027,8 +2038,10 @@ mod tests {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["echo".into(), "ok".into()],
|
||||
parsed: Vec::new(),
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -2051,8 +2064,10 @@ mod tests {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), long],
|
||||
parsed: Vec::new(),
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -2074,8 +2089,10 @@ mod tests {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), cmd],
|
||||
parsed: Vec::new(),
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -2098,8 +2115,10 @@ mod tests {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), cmd],
|
||||
parsed: Vec::new(),
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -2122,8 +2141,10 @@ mod tests {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
|
||||
parsed: Vec::new(),
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
@@ -2172,8 +2193,10 @@ mod tests {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
|
||||
parsed: Vec::new(),
|
||||
subagent: None,
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
is_user_shell_command: false,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
|
||||
@@ -213,6 +213,10 @@ pub async fn run_main(
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
compact_prompt: None,
|
||||
max_active_subagents: None,
|
||||
root_agent_uses_user_messages: None,
|
||||
subagent_root_inbox_autosubmit: None,
|
||||
subagent_inbox_inject_before_tools: None,
|
||||
include_apply_patch_tool: None,
|
||||
show_raw_agent_reasoning: cli.oss.then_some(true),
|
||||
tools_web_search_request: None,
|
||||
|
||||
@@ -720,9 +720,11 @@ mod tests {
|
||||
"exec-1".into(),
|
||||
vec!["bash".into(), "-lc".into(), "ls".into()],
|
||||
vec![ParsedCommand::Unknown { cmd: "ls".into() }],
|
||||
None,
|
||||
ExecCommandSource::Agent,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
exec_cell.complete_call(
|
||||
"exec-1",
|
||||
|
||||
@@ -28,6 +28,7 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
elapsed_running: Duration,
|
||||
last_resume_at: Instant,
|
||||
is_paused: bool,
|
||||
subagent_count: usize,
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
@@ -62,6 +63,7 @@ impl StatusIndicatorWidget {
|
||||
elapsed_running: Duration::ZERO,
|
||||
last_resume_at: Instant::now(),
|
||||
is_paused: false,
|
||||
subagent_count: 0,
|
||||
|
||||
app_event_tx,
|
||||
frame_requester,
|
||||
@@ -87,6 +89,14 @@ impl StatusIndicatorWidget {
|
||||
self.show_interrupt_hint = visible;
|
||||
}
|
||||
|
||||
pub(crate) fn set_subagent_count(&mut self, count: usize) {
|
||||
if self.subagent_count == count {
|
||||
return;
|
||||
}
|
||||
self.subagent_count = count;
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn interrupt_hint_visible(&self) -> bool {
|
||||
self.show_interrupt_hint
|
||||
@@ -160,20 +170,34 @@ impl Renderable for StatusIndicatorWidget {
|
||||
spans.push(self.header.clone().into());
|
||||
}
|
||||
spans.push(" ".into());
|
||||
let mut status_parts = vec![pretty_elapsed];
|
||||
if self.subagent_count > 0 {
|
||||
status_parts.push(format_subagent_count(self.subagent_count));
|
||||
}
|
||||
let base_status = status_parts.join(" • ");
|
||||
if self.show_interrupt_hint {
|
||||
spans.extend(vec![
|
||||
format!("({pretty_elapsed} • ").dim(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" to interrupt)".dim(),
|
||||
]);
|
||||
let mut prefix = format!("({base_status}");
|
||||
prefix.push_str(" • ");
|
||||
spans.push(prefix.dim());
|
||||
spans.push(key_hint::plain(KeyCode::Esc).into());
|
||||
spans.push(" to interrupt)".dim());
|
||||
} else {
|
||||
spans.push(format!("({pretty_elapsed})").dim());
|
||||
let status_text = format!("({base_status})");
|
||||
spans.push(status_text.dim());
|
||||
}
|
||||
|
||||
Line::from(spans).render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_subagent_count(count: usize) -> String {
|
||||
if count == 1 {
|
||||
"1 subagent".to_string()
|
||||
} else {
|
||||
format!("{count} subagents")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -181,6 +205,8 @@ mod tests {
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -229,6 +255,37 @@ mod tests {
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_with_subagent_count() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
w.set_subagent_count(3);
|
||||
|
||||
let area = Rect::new(0, 0, 80, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
w.render(area, &mut buf);
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row.contains("3 subagents"),
|
||||
"count should show even when interrupt hint is visible: {row}"
|
||||
);
|
||||
|
||||
w.set_interrupt_hint_visible(false);
|
||||
w.render(area, &mut buf);
|
||||
row.clear();
|
||||
for x in 0..area.width {
|
||||
row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row.contains("3 subagents"),
|
||||
"expected count when no interrupt hint: {row}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_pauses_when_requested() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -35,6 +35,7 @@ Optional and experimental capabilities are toggled via the `[features]` table in
|
||||
[features]
|
||||
streamable_shell = true # enable the streamable exec tool
|
||||
web_search_request = true # allow the model to request web searches
|
||||
# subagent_tools = true # expose spawn/fork/list/await/logs/prune subagent tools
|
||||
# view_image_tool defaults to true; omit to keep defaults
|
||||
```
|
||||
|
||||
@@ -48,6 +49,7 @@ Supported features:
|
||||
| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool |
|
||||
| `view_image_tool` | true | Stable | Include the `view_image` tool |
|
||||
| `web_search_request` | false | Stable | Allow the model to issue web searches |
|
||||
| `subagent_tools` | false | Experimental | Enable built-in subagent orchestration tools |
|
||||
| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment |
|
||||
| `ghost_commit` | false | Experimental | Create a ghost commit each turn |
|
||||
| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox |
|
||||
@@ -349,6 +351,58 @@ This is reasonable to use if Codex is running in an environment that provides it
|
||||
|
||||
Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows.
|
||||
|
||||
### max_active_subagents
|
||||
|
||||
Controls how many subagents (spawned or forked conversations) may run concurrently within a single Codex session. Each child keeps one slot until it is pruned or fully shut down, so this setting bounds concurrency as well as memory use.
|
||||
|
||||
```toml
|
||||
# allow at most eight active subagents (default)
|
||||
max_active_subagents = 8
|
||||
```
|
||||
|
||||
When the limit is reached, additional `spawn`/`fork` tool calls immediately return an error telling the model to prune or await existing children before launching new work. Values below 1 are rejected, and values above 64 are clamped to 64 to prevent runaway resource use.
|
||||
|
||||
### root_agent_uses_user_messages
|
||||
|
||||
Controls how the root agent’s messages to a subagent are represented in the subagent’s own history. When `true` (default), root-to-subagent messages are injected as `user` turns in the child. When `false`, every cross-agent message arrives only via `subagent_await` tool results, so the child must explicitly read the tool output to see root instructions.
|
||||
|
||||
### subagent_root_inbox_autosubmit
|
||||
|
||||
Controls whether the root agent automatically drains its inbox (and any child
|
||||
messages destined for agent `0`) at safe stopping points between model turns,
|
||||
and whether it may auto-start a follow-up turn based on those messages. When
|
||||
`true`:
|
||||
|
||||
- At the end of each model turn for the root, Codex drains the root inbox and
|
||||
injects synthetic `subagent_await` call/output pairs—complete with
|
||||
`completion_status`—directly into the root transcript in timestamp order.
|
||||
- If a model turn finished without any tool calls and the inbox drain produced
|
||||
entries, Codex immediately launches another Responses turn so the root can
|
||||
react without waiting for fresh user input.
|
||||
- When the root session is otherwise idle, the drain still records a lightweight
|
||||
autosubmitted turn containing the pending `subagent_await` items. This keeps
|
||||
the conversation history current even before the next user-initiated turn.
|
||||
|
||||
When `false`, the root must call `subagent_await` explicitly to see inbox
|
||||
messages during a turn, and no autosubmitted turns are emitted while idle.
|
||||
|
||||
### subagent_inbox_inject_before_tools
|
||||
|
||||
Controls where synthetic `subagent_await` tool calls and outputs derived from
|
||||
inbox delivery are injected relative to real tool outputs inside a turn.
|
||||
|
||||
- When `false` (default), Codex records the model’s tool call and tool
|
||||
output(s) for a turn first, and only then appends synthetic `subagent_await`
|
||||
calls/outputs derived from inbox messages (Option A). This is closer to
|
||||
training-time patterns where the model generally sees its own tool call and
|
||||
result before extra context.
|
||||
- When `true`, Codex records synthetic `subagent_await` calls/outputs first
|
||||
and then appends tool outputs (Option B), which is closer to strict
|
||||
chronological ordering when inbox messages arrive while tools are running.
|
||||
|
||||
This flag only affects how Codex orders conversation items in history; it
|
||||
never splices synthetic items into the middle of an in-flight streaming turn.
|
||||
|
||||
### tools.\*
|
||||
|
||||
Use the optional `[tools]` table to toggle built-in tools that the agent may call. `web_search` stays off unless you opt in, while `view_image` is now enabled by default:
|
||||
|
||||
Reference in New Issue
Block a user