Make parent-owned sub-agent threads read-only in the TUI (#33841)

## Why

Multi-agent V2 spawned threads are controlled by their parent and reject direct
app-server input. The TUI should expose that ownership instead of presenting an
input flow that the server will reject.

## What changed

- Add the experimental `Thread.canAcceptDirectInput` capability for loaded
  threads and populate it from the same policy used to validate `turn/start`
  and `turn/steer`. Stored, unloaded threads report `null`.
- Carry the capability through TUI thread discovery, selection, startup, and
  resume flows, with a compatibility fallback for older app servers.
- Present parent-owned threads as view-only, preserve drafts and queued input,
  block agent-directed commands and settings shortcuts, and keep local
  navigation and inspection commands available.

## Testing

- Cover V1 writable and V2 view-only spawned threads through selection and
  resume.
- Cover composer submission paths, queued and initial input preservation,
  allowed navigation commands, settings shortcuts, and the view-only UI.

GitOrigin-RevId: 495ad051249e5577fb0b0c1cc8c40a0998d04918
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-07-17 15:14:47 +00:00
1 parent 315195492c
commit 3f0669dbd6
37 files changed
+781 -99

No files matched your search

@@ -212,6 +212,7 @@ fn sample_thread_with_metadata(
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source, source,
can_accept_direct_input: None,
thread_source, thread_source,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
+1
View File
@@ -470,6 +470,7 @@ fn sample_thread(thread_id: &str) -> Thread {
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: AppServerSessionSource::Exec, source: AppServerSessionSource::Exec,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -2639,6 +2639,7 @@ mod tests {
cwd: cwd.clone(), cwd: cwd.clone(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: v2::SessionSource::Exec, source: v2::SessionSource::Exec,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -2692,6 +2693,7 @@ mod tests {
"cwd": absolute_path_string("tmp"), "cwd": absolute_path_string("tmp"),
"cliVersion": "0.0.0", "cliVersion": "0.0.0",
"source": "exec", "source": "exec",
"canAcceptDirectInput": null,
"threadSource": null, "threadSource": null,
"agentNickname": null, "agentNickname": null,
"agentRole": null, "agentRole": null,
@@ -195,6 +195,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
cwd: absolute_path("tmp"), cwd: absolute_path("tmp"),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: SessionSource::Exec, source: SessionSource::Exec,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -208,6 +208,10 @@ pub struct Thread {
pub cli_version: String, pub cli_version: String,
/// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
pub source: SessionSource, pub source: SessionSource,
/// Whether the app server accepts direct turn input for this loaded thread.
/// `None` means the capability is unavailable, such as for an unloaded stored thread.
#[experimental("thread.canAcceptDirectInput")]
pub can_accept_direct_input: Option<bool>,
/// Optional analytics source classification for this thread. /// Optional analytics source classification for this thread.
pub thread_source: Option<ThreadSource>, pub thread_source: Option<ThreadSource>,
/// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
+1 -1
View File
@@ -143,7 +143,7 @@ Example with notification opt-out:
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. For loaded threads, experimental clients can use `canAcceptDirectInput` to determine whether `turn/start` and `turn/steer` are accepted; unloaded stored threads report `null` when that capability is unavailable.
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
- `thread/items/list` — experimental; page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination. - `thread/items/list` — experimental; page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
@@ -1,4 +1,5 @@
use super::thread_fork_goal::inherit_thread_goal_snapshot; use super::thread_fork_goal::inherit_thread_goal_snapshot;
use super::turn_processor::can_accept_direct_input;
use super::*; use super::*;
use crate::error_code::method_not_found; use crate::error_code::method_not_found;
use codex_app_server_protocol::SelectedCapabilityRoot; use codex_app_server_protocol::SelectedCapabilityRoot;
@@ -1281,6 +1282,7 @@ impl ThreadRequestProcessor {
let mut thread = build_thread_from_snapshot( let mut thread = build_thread_from_snapshot(
thread_id, thread_id,
session_configured.session_id.to_string(), session_configured.session_id.to_string(),
thread.multi_agent_version(),
&config_snapshot, &config_snapshot,
session_configured.rollout_path.clone(), session_configured.rollout_path.clone(),
); );
@@ -2297,8 +2299,12 @@ impl ThreadRequestProcessor {
.load_persisted_thread_for_read(thread_id, include_turns) .load_persisted_thread_for_read(thread_id, include_turns)
.await? .await?
{ {
// Persisted metadata-only read: no live thread state is needed. if let Some(loaded_thread) = loaded_thread.as_ref() {
thread self.load_live_thread_view(thread_id, include_turns, loaded_thread, Some(thread))
.await?
} else {
thread
}
} else if let Some(loaded_thread) = loaded_thread.as_ref() { } else if let Some(loaded_thread) = loaded_thread.as_ref() {
// Loaded metadata-only read before persistence is materialized: build // Loaded metadata-only read before persistence is materialized: build
// the response from the live thread snapshot. // the response from the live thread snapshot.
@@ -2426,6 +2432,7 @@ impl ThreadRequestProcessor {
} }
thread.session_id.clone_from(&fallback_thread.session_id); thread.session_id.clone_from(&fallback_thread.session_id);
thread.ephemeral = fallback_thread.ephemeral; thread.ephemeral = fallback_thread.ephemeral;
thread.can_accept_direct_input = fallback_thread.can_accept_direct_input;
thread thread
} else { } else {
fallback_thread fallback_thread
@@ -2803,6 +2810,7 @@ impl ThreadRequestProcessor {
let loaded_thread = build_thread_from_snapshot( let loaded_thread = build_thread_from_snapshot(
thread_id, thread_id,
thread.session_configured().session_id.to_string(), thread.session_configured().session_id.to_string(),
thread.multi_agent_version(),
&config_snapshot, &config_snapshot,
thread.rollout_path(), thread.rollout_path(),
); );
@@ -3383,6 +3391,10 @@ impl ThreadRequestProcessor {
); );
thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
thread_summary.thread_source = config_snapshot.thread_source.clone().map(Into::into); thread_summary.thread_source = config_snapshot.thread_source.clone().map(Into::into);
thread_summary.can_accept_direct_input = Some(can_accept_direct_input(
existing_thread.multi_agent_version(),
&config_snapshot.session_source,
));
let instruction_sources = existing_thread.legacy_instruction_sources().await; let instruction_sources = existing_thread.legacy_instruction_sources().await;
let listener_command_tx = { let listener_command_tx = {
@@ -3594,6 +3606,10 @@ impl ThreadRequestProcessor {
) -> std::result::Result<Thread, String> { ) -> std::result::Result<Thread, String> {
let config_snapshot = thread.config_snapshot().await; let config_snapshot = thread.config_snapshot().await;
let session_id = thread.session_configured().session_id.to_string(); let session_id = thread.session_configured().session_id.to_string();
let can_accept_direct_input = can_accept_direct_input(
thread.multi_agent_version(),
&config_snapshot.session_source,
);
let thread = match thread_history { let thread = match thread_history {
InitialHistory::Resumed(resumed) => { InitialHistory::Resumed(resumed) => {
let fallback_provider = config_snapshot.model_provider_id.as_str(); let fallback_provider = config_snapshot.model_provider_id.as_str();
@@ -3656,6 +3672,7 @@ impl ThreadRequestProcessor {
let mut thread = build_thread_from_snapshot( let mut thread = build_thread_from_snapshot(
thread_id, thread_id,
session_id.clone(), session_id.clone(),
thread.multi_agent_version(),
&config_snapshot, &config_snapshot,
Some(rollout_path.into()), Some(rollout_path.into()),
); );
@@ -3667,6 +3684,7 @@ impl ThreadRequestProcessor {
)), )),
}; };
let mut thread = thread?; let mut thread = thread?;
thread.can_accept_direct_input = Some(can_accept_direct_input);
thread.id = thread_id.to_string(); thread.id = thread_id.to_string();
thread.session_id = session_id; thread.session_id = session_id;
thread.path = Some(rollout_path.to_path_buf()); thread.path = Some(rollout_path.to_path_buf());
@@ -3926,6 +3944,8 @@ impl ThreadRequestProcessor {
"thread", "thread",
); );
let config_snapshot = forked_thread.config_snapshot().await;
// Persistent forks materialize their own rollout immediately. Ephemeral forks stay // Persistent forks materialize their own rollout immediately. Ephemeral forks stay
// pathless, so they rebuild their visible history from the copied source history instead. // pathless, so they rebuild their visible history from the copied source history instead.
let mut thread = if session_configured.rollout_path.is_some() { let mut thread = if session_configured.rollout_path.is_some() {
@@ -3938,10 +3958,10 @@ impl ThreadRequestProcessor {
include_turns, include_turns,
) )
} else { } else {
let config_snapshot = forked_thread.config_snapshot().await;
let mut thread = build_thread_from_snapshot( let mut thread = build_thread_from_snapshot(
thread_id, thread_id,
session_configured.session_id.to_string(), session_configured.session_id.to_string(),
forked_thread.multi_agent_version(),
&config_snapshot, &config_snapshot,
/*path*/ None, /*path*/ None,
); );
@@ -3959,12 +3979,12 @@ impl ThreadRequestProcessor {
if let Some(name) = source_thread_name { if let Some(name) = source_thread_name {
set_thread_name_from_title(&mut thread, name); set_thread_name_from_title(&mut thread, name);
} }
thread.can_accept_direct_input = Some(can_accept_direct_input(
forked_thread.multi_agent_version(),
&config_snapshot.session_source,
));
thread.session_id = session_configured.session_id.to_string(); thread.session_id = session_configured.session_id.to_string();
thread.thread_source = forked_thread thread.thread_source = config_snapshot.thread_source.clone().map(Into::into);
.config_snapshot()
.await
.thread_source
.map(Into::into);
self.thread_watch_manager self.thread_watch_manager
.upsert_thread_silently(thread.clone()) .upsert_thread_silently(thread.clone())
@@ -3976,7 +3996,6 @@ impl ThreadRequestProcessor {
.await, .await,
/*has_in_progress_turn*/ false, /*has_in_progress_turn*/ false,
); );
let config_snapshot = forked_thread.config_snapshot().await;
let sandbox = config_snapshot.sandbox_policy().into(); let sandbox = config_snapshot.sandbox_policy().into();
let active_permission_profile = let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile); thread_response_active_permission_profile(config_snapshot.active_permission_profile);
@@ -4746,6 +4765,7 @@ pub(crate) fn thread_from_stored_thread(
agent_nickname: source.get_nickname(), agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(), agent_role: source.get_agent_role(),
source: source.into(), source: source.into(),
can_accept_direct_input: None,
thread_source: thread.thread_source.map(Into::into), thread_source: thread.thread_source.map(Into::into),
git_info, git_info,
name: thread.name, name: thread.name,
@@ -4927,6 +4947,7 @@ fn permission_profile_trusts_project(
fn build_thread_from_snapshot( fn build_thread_from_snapshot(
thread_id: ThreadId, thread_id: ThreadId,
session_id: String, session_id: String,
multi_agent_version: Option<codex_protocol::protocol::MultiAgentVersion>,
config_snapshot: &ThreadConfigSnapshot, config_snapshot: &ThreadConfigSnapshot,
path: Option<PathBuf>, path: Option<PathBuf>,
) -> Thread { ) -> Thread {
@@ -4951,6 +4972,10 @@ fn build_thread_from_snapshot(
agent_nickname: config_snapshot.session_source.get_nickname(), agent_nickname: config_snapshot.session_source.get_nickname(),
agent_role: config_snapshot.session_source.get_agent_role(), agent_role: config_snapshot.session_source.get_agent_role(),
source: config_snapshot.session_source.clone().into(), source: config_snapshot.session_source.clone().into(),
can_accept_direct_input: Some(can_accept_direct_input(
multi_agent_version,
&config_snapshot.session_source,
)),
thread_source: config_snapshot.thread_source.clone().map(Into::into), thread_source: config_snapshot.thread_source.clone().map(Into::into),
git_info: None, git_info: None,
name: None, name: None,
@@ -4994,6 +5019,7 @@ fn build_thread_from_loaded_snapshot(
build_thread_from_snapshot( build_thread_from_snapshot(
thread_id, thread_id,
loaded_thread.session_configured().session_id.to_string(), loaded_thread.session_configured().session_id.to_string(),
loaded_thread.multi_agent_version(),
config_snapshot, config_snapshot,
loaded_thread.rollout_path(), loaded_thread.rollout_path(),
) )
@@ -204,6 +204,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: SessionSource::Cli, source: SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -319,6 +319,7 @@ pub(crate) fn summary_to_thread(
agent_nickname: source.get_nickname(), agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(), agent_role: source.get_agent_role(),
source: source.into(), source: source.into(),
can_accept_direct_input: None,
thread_source: None, thread_source: None,
git_info, git_info,
name: None, name: None,
@@ -18,6 +18,18 @@ use crate::image_url::is_remote_image_url;
const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str = const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str =
"direct app-server input is not allowed for multi-agent v2 sub-agents"; "direct app-server input is not allowed for multi-agent v2 sub-agents";
/// Mirrors the direct-input policy in both request validation and thread capability responses.
pub(super) fn can_accept_direct_input(
multi_agent_version: Option<MultiAgentVersion>,
session_source: &SessionSource,
) -> bool {
multi_agent_version != Some(MultiAgentVersion::V2)
|| !matches!(
session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
)
}
fn validate_user_input_image_urls(input: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { fn validate_user_input_image_urls(input: &[V2UserInput]) -> Result<(), JSONRPCErrorError> {
if input.iter().any(|item| { if input.iter().any(|item| {
matches!( matches!(
@@ -326,12 +338,11 @@ impl TurnRequestProcessor {
request_id: &ConnectionRequestId, request_id: &ConnectionRequestId,
thread: &CodexThread, thread: &CodexThread,
) -> Result<(), JSONRPCErrorError> { ) -> Result<(), JSONRPCErrorError> {
if thread.multi_agent_version() == Some(MultiAgentVersion::V2) let config_snapshot = thread.config_snapshot().await;
&& matches!( if !can_accept_direct_input(
thread.config_snapshot().await.session_source, thread.multi_agent_version(),
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) &config_snapshot.session_source,
) ) {
{
let error = invalid_request(DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR); let error = invalid_request(DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR);
self.track_error_response(request_id, &error, /*error_type*/ None); self.track_error_response(request_id, &error, /*error_type*/ None);
return Err(error); return Err(error);
+1
View File
@@ -907,6 +907,7 @@ mod tests {
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
source, source,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
git_info: None, git_info: None,
name: None, name: None,
+2
View File
@@ -350,6 +350,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
cwd: test_path_buf("/tmp/project").abs(), cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0-test".to_string(), cli_version: "0.0.0-test".to_string(),
source: codex_app_server_protocol::SessionSource::Exec, source: codex_app_server_protocol::SessionSource::Exec,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -812,6 +813,7 @@ fn sample_thread_start_response() -> ThreadStartResponse {
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli, source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: Some(codex_app_server_protocol::ThreadSource::User), thread_source: Some(codex_app_server_protocol::ThreadSource::User),
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
+3
View File
@@ -1082,6 +1082,9 @@ See the Codex keymap documentation for supported actions and examples."
let initial_session_started_at = Instant::now(); let initial_session_started_at = Instant::now();
if let Some(started) = initial_started_thread { if let Some(started) = initial_started_thread {
let thread_id = started.session.thread_id; let thread_id = started.session.thread_id;
if started.blocks_direct_input {
app.mark_primary_thread_parent_owned(thread_id);
}
app.enqueue_primary_thread_session(started.session, started.turns) app.enqueue_primary_thread_session(started.session, started.turns)
.await?; .await?;
if should_prompt_for_paused_goal_after_startup_resume { if should_prompt_for_paused_goal_after_startup_resume {
+29
View File
@@ -6,6 +6,7 @@
//! //!
//! Responsibilities here are intentionally narrow: //! Responsibilities here are intentionally narrow:
//! - remember picker entries and their first-seen order //! - remember picker entries and their first-seen order
//! - remember which V2 child threads are owned by their parent agent
//! - answer traversal questions like "what is the next thread?" //! - answer traversal questions like "what is the next thread?"
//! - derive user-facing picker/footer text from cached thread metadata //! - derive user-facing picker/footer text from cached thread metadata
//! //!
@@ -26,6 +27,7 @@ use crate::multi_agents::previous_agent_shortcut;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use ratatui::text::Span; use ratatui::text::Span;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet;
/// Small state container for multi-agent picker ordering and labeling. /// Small state container for multi-agent picker ordering and labeling.
/// ///
@@ -42,6 +44,8 @@ pub(crate) struct AgentNavigationState {
threads: HashMap<ThreadId, AgentPickerThreadEntry>, threads: HashMap<ThreadId, AgentPickerThreadEntry>,
/// Stable first-seen traversal order for picker rows and keyboard cycling. /// Stable first-seen traversal order for picker rows and keyboard cycling.
order: Vec<ThreadId>, order: Vec<ThreadId>,
/// Spawned child threads whose instructions are owned by their parent agent.
parent_owned_threads: HashSet<ThreadId>,
} }
/// Direction of keyboard traversal through the stable picker order. /// Direction of keyboard traversal through the stable picker order.
@@ -64,6 +68,15 @@ impl AgentNavigationState {
self.threads.get(thread_id) self.threads.get(thread_id)
} }
pub(crate) fn is_parent_owned(&self, thread_id: ThreadId) -> bool {
self.parent_owned_threads.contains(&thread_id)
}
/// Marks a spawned child thread as view-only for direct user instructions.
pub(crate) fn mark_parent_owned(&mut self, thread_id: ThreadId) {
self.parent_owned_threads.insert(thread_id);
}
/// Returns whether the picker cache currently knows about any threads. /// Returns whether the picker cache currently knows about any threads.
/// ///
/// This is the cheapest way for `App` to decide whether opening the picker should show "No /// This is the cheapest way for `App` to decide whether opening the picker should show "No
@@ -162,6 +175,7 @@ impl AgentNavigationState {
pub(crate) fn clear(&mut self) { pub(crate) fn clear(&mut self) {
self.threads.clear(); self.threads.clear();
self.order.clear(); self.order.clear();
self.parent_owned_threads.clear();
} }
/// Removes a tracked thread entirely from picker metadata and traversal order. /// Removes a tracked thread entirely from picker metadata and traversal order.
@@ -172,6 +186,7 @@ impl AgentNavigationState {
pub(crate) fn remove(&mut self, thread_id: ThreadId) { pub(crate) fn remove(&mut self, thread_id: ThreadId) {
self.threads.remove(&thread_id); self.threads.remove(&thread_id);
self.order.retain(|candidate| *candidate != thread_id); self.order.retain(|candidate| *candidate != thread_id);
self.parent_owned_threads.remove(&thread_id);
} }
/// Returns whether there is at least one tracked thread other than the primary one. /// Returns whether there is at least one tracked thread other than the primary one.
@@ -376,6 +391,20 @@ mod tests {
); );
} }
#[test]
fn parent_owned_state_is_removed_with_thread_metadata() {
let (mut state, _main_thread_id, first_agent_id, second_agent_id) = populated_state();
state.mark_parent_owned(first_agent_id);
assert!(state.is_parent_owned(first_agent_id));
state.remove(first_agent_id);
assert!(!state.is_parent_owned(first_agent_id));
state.mark_parent_owned(second_agent_id);
state.clear();
assert!(!state.is_parent_owned(second_agent_id));
}
#[test] #[test]
fn adjacent_thread_id_wraps_in_spawn_order() { fn adjacent_thread_id_wraps_in_spawn_order() {
let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state(); let (state, main_thread_id, first_agent_id, second_agent_id) = populated_state();
+8 -1
View File
@@ -14,6 +14,7 @@
//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are //! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are
//! found. The primary thread itself is never included in the output. //! found. The primary thread itself is never included in the output.
use crate::app_server_session::thread_blocks_direct_input;
use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread; use codex_app_server_protocol::Thread;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
@@ -29,6 +30,7 @@ pub(crate) struct LoadedSubagentThread {
pub(crate) agent_nickname: Option<String>, pub(crate) agent_nickname: Option<String>,
pub(crate) agent_role: Option<String>, pub(crate) agent_role: Option<String>,
pub(crate) agent_path: Option<String>, pub(crate) agent_path: Option<String>,
pub(crate) blocks_direct_input: bool,
} }
/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent. /// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent.
@@ -84,6 +86,7 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
threads_by_id threads_by_id
.remove(&thread_id) .remove(&thread_id)
.map(|thread| LoadedSubagentThread { .map(|thread| LoadedSubagentThread {
blocks_direct_input: thread_blocks_direct_input(&thread),
thread_id, thread_id,
agent_nickname: thread.agent_nickname, agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role, agent_role: thread.agent_role,
@@ -144,6 +147,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source, source,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -191,6 +195,7 @@ mod tests {
); );
child.agent_nickname = Some("Scout".to_string()); child.agent_nickname = Some("Scout".to_string());
child.agent_role = Some("explorer".to_string()); child.agent_role = Some("explorer".to_string());
child.can_accept_direct_input = Some(true);
let mut grandchild = test_thread( let mut grandchild = test_thread(
grandchild_thread_id, grandchild_thread_id,
@@ -198,7 +203,7 @@ mod tests {
); );
grandchild.agent_nickname = Some("Atlas".to_string()); grandchild.agent_nickname = Some("Atlas".to_string());
grandchild.agent_role = Some("worker".to_string()); grandchild.agent_role = Some("worker".to_string());
grandchild.can_accept_direct_input = Some(false);
let unrelated_child = test_thread( let unrelated_child = test_thread(
unrelated_child_id, unrelated_child_id,
thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "researcher"), thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "researcher"),
@@ -218,12 +223,14 @@ mod tests {
loaded, loaded,
vec![ vec![
LoadedSubagentThread { LoadedSubagentThread {
blocks_direct_input: false,
thread_id: child_thread_id, thread_id: child_thread_id,
agent_nickname: Some("Scout".to_string()), agent_nickname: Some("Scout".to_string()),
agent_role: Some("explorer".to_string()), agent_role: Some("explorer".to_string()),
agent_path: None, agent_path: None,
}, },
LoadedSubagentThread { LoadedSubagentThread {
blocks_direct_input: true,
thread_id: grandchild_thread_id, thread_id: grandchild_thread_id,
agent_nickname: Some("Atlas".to_string()), agent_nickname: Some("Atlas".to_string()),
agent_role: Some("worker".to_string()), agent_role: Some("worker".to_string()),
+33 -3
View File
@@ -5,6 +5,8 @@
//! cache used for multi-agent navigation. //! cache used for multi-agent navigation.
use super::*; use super::*;
use crate::app_server_session::source_agent_path;
use crate::app_server_session::thread_blocks_direct_input;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(super) enum ThreadAttachPresentation { pub(super) enum ThreadAttachPresentation {
@@ -184,6 +186,12 @@ impl App {
self.sync_active_agent_label(); self.sync_active_agent_label();
} }
/// Persists the app-server's authoritative ownership flag and updates the active composer.
pub(super) fn mark_primary_thread_parent_owned(&mut self, thread_id: ThreadId) {
self.agent_navigation.mark_parent_owned(thread_id);
self.chat_widget.set_parent_owned_thread();
}
/// Marks a cached picker thread closed and recomputes the contextual footer label. /// Marks a cached picker thread closed and recomputes the contextual footer label.
/// ///
/// Closing a thread is not the same as removing it: users can still inspect finished agent /// Closing a thread is not the same as removing it: users can still inspect finished agent
@@ -205,6 +213,8 @@ impl App {
.await .await
{ {
Ok(thread) => { Ok(thread) => {
let is_parent_owned = thread_blocks_direct_input(&thread);
let agent_path = source_agent_path(&thread.source);
let is_running = matches!( let is_running = matches!(
thread.status, thread.status,
codex_app_server_protocol::ThreadStatus::Active { .. } codex_app_server_protocol::ThreadStatus::Active { .. }
@@ -227,6 +237,10 @@ impl App {
}), }),
is_closed, is_closed,
); );
if is_parent_owned {
self.agent_navigation.mark_parent_owned(thread_id);
}
self.agent_navigation.set_agent_path(thread_id, agent_path);
self.agent_navigation.set_running(thread_id, is_running); self.agent_navigation.set_running(thread_id, is_running);
true true
} }
@@ -278,7 +292,12 @@ impl App {
.resume_thread(self.config.clone(), thread_id, self.resume_model_settings()) .resume_thread(self.config.clone(), thread_id, self.resume_model_settings())
.await .await
{ {
Ok(started) => (started.session, started.turns, true), Ok(started) => {
if started.blocks_direct_input {
self.agent_navigation.mark_parent_owned(thread_id);
}
(started.session, started.turns, true)
}
Err(resume_err) => { Err(resume_err) => {
tracing::warn!( tracing::warn!(
thread_id = %thread_id, thread_id = %thread_id,
@@ -369,7 +388,6 @@ impl App {
.add_error_message(format!("Agent thread {thread_id} is no longer available.")); .add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(()); return Ok(());
} }
let mut is_replay_only = self let mut is_replay_only = self
.agent_navigation .agent_navigation
.get(&thread_id) .get(&thread_id)
@@ -398,7 +416,6 @@ impl App {
.add_error_message(format!("Agent thread {thread_id} is no longer available.")); .add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(()); return Ok(());
} }
let previous_thread_id = self.active_thread_id; let previous_thread_id = self.active_thread_id;
self.store_active_thread_receiver().await; self.store_active_thread_receiver().await;
self.active_thread_id = None; self.active_thread_id = None;
@@ -419,6 +436,7 @@ impl App {
&mut snapshot, &mut snapshot,
) )
.await; .await;
let blocks_direct_input = self.agent_navigation.is_parent_owned(thread_id);
self.active_thread_id = Some(thread_id); self.active_thread_id = Some(thread_id);
self.active_thread_rx = Some(receiver); self.active_thread_rx = Some(receiver);
@@ -429,6 +447,9 @@ impl App {
/*initial_user_message*/ None, /*initial_user_message*/ None,
); );
self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.replace_chat_widget(ChatWidget::new_with_app_event(init));
if blocks_direct_input {
self.chat_widget.set_parent_owned_thread();
}
self.reset_for_thread_switch(tui)?; self.reset_for_thread_switch(tui)?;
self.replay_thread_snapshot(snapshot, !is_replay_only); self.replay_thread_snapshot(snapshot, !is_replay_only);
@@ -519,6 +540,9 @@ impl App {
.set_queue_submissions_until_session_configured(/*queue*/ false); .set_queue_submissions_until_session_configured(/*queue*/ false);
match result { match result {
Ok(started) => { Ok(started) => {
if started.blocks_direct_input {
self.mark_primary_thread_parent_owned(started.session.thread_id);
}
self.enqueue_primary_thread_session(started.session, started.turns) self.enqueue_primary_thread_session(started.session, started.turns)
.await?; .await?;
self.chat_widget.maybe_send_next_queued_input(); self.chat_widget.maybe_send_next_queued_input();
@@ -625,6 +649,9 @@ impl App {
initial_user_message, initial_user_message,
); );
self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.replace_chat_widget(ChatWidget::new_with_app_event(init));
if started.blocks_direct_input {
self.mark_primary_thread_parent_owned(started.session.thread_id);
}
self.enqueue_primary_thread_session_with_presentation( self.enqueue_primary_thread_session_with_presentation(
started.session, started.session,
started.turns, started.turns,
@@ -694,6 +721,9 @@ impl App {
for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) { for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) {
let agent_path = thread.agent_path; let agent_path = thread.agent_path;
if thread.blocks_direct_input {
self.agent_navigation.mark_parent_owned(thread.thread_id);
}
self.upsert_agent_picker_thread( self.upsert_agent_picker_thread(
thread.thread_id, thread.thread_id,
thread.agent_nickname, thread.agent_nickname,
+152
View File
@@ -99,7 +99,11 @@ use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::UserMessageEvent;
@@ -1372,6 +1376,7 @@ async fn open_agent_picker_clears_completed_path_backed_agent_running_state() ->
agent_path: "/root/child".to_string(), agent_path: "/root/child".to_string(),
is_running_hint: true, is_running_hint: true,
}); });
assert!(!app.agent_navigation.is_parent_owned(thread_id));
Box::pin(app.open_agent_picker(&mut app_server)).await; Box::pin(app.open_agent_picker(&mut app_server)).await;
@@ -1523,6 +1528,147 @@ fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
}) })
} }
#[test]
fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
runtime.block_on(async {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let root = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let root_thread_id = root.session.thread_id;
app.enqueue_primary_thread_session(root.session, root.turns)
.await?;
let rollout_dir = app
.config
.codex_home
.join("sessions")
.join("2026")
.join("01")
.join("01");
std::fs::create_dir_all(&rollout_dir)?;
let mut child_thread_ids = Vec::new();
for (index, multi_agent_version) in [MultiAgentVersion::V1, MultiAgentVersion::V2]
.into_iter()
.enumerate()
{
let child_thread_id = ThreadId::new();
let timestamp = format!("2026-01-01T00:00:0{index}Z");
let session_meta = SessionMeta {
session_id: child_thread_id.into(),
id: child_thread_id,
parent_thread_id: Some(root_thread_id),
timestamp: timestamp.clone(),
cwd: app.config.cwd.to_path_buf(),
originator: "codex-tui-test".to_string(),
cli_version: "0.0.0".to_string(),
source: RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root_thread_id,
depth: 1,
agent_path: None,
agent_nickname: Some(format!("child-{index}")),
agent_role: Some("worker".to_string()),
}),
model_provider: Some(app.config.model_provider_id.clone()),
multi_agent_version: Some(multi_agent_version),
..SessionMeta::default()
};
let rollout_path = rollout_dir.join(format!(
"rollout-2026-01-01T00-00-0{index}-{child_thread_id}.jsonl"
));
let session_meta_line = serde_json::json!({
"timestamp": timestamp,
"type": "session_meta",
"payload": serde_json::to_value(session_meta)?,
});
std::fs::write(rollout_path, format!("{session_meta_line}\n"))?;
assert!(
app.attach_live_thread_for_selection(&mut app_server, child_thread_id)
.await?
);
assert_eq!(
app.agent_navigation.is_parent_owned(child_thread_id),
multi_agent_version == MultiAgentVersion::V2
);
child_thread_ids.push(child_thread_id);
}
assert!(app.backfill_loaded_subagent_threads(&mut app_server).await);
assert!(!app.agent_navigation.is_parent_owned(child_thread_ids[0]));
assert!(app.agent_navigation.is_parent_owned(child_thread_ids[1]));
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[0])
.await?;
while app_event_rx.try_recv().is_ok() {}
app.chat_widget
.restore_user_message_to_composer("v1 remains writable".into());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
std::iter::from_fn(|| app_event_rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
);
app.select_agent_thread(&mut tui, &mut app_server, child_thread_ids[1])
.await?;
while app_event_rx.try_recv().is_ok() {}
app.chat_widget
.restore_user_message_to_composer("v2 stays view-only".into());
let draft = app.chat_widget.composer_text_with_pending();
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
assert!(
!std::iter::from_fn(|| app_event_rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
);
let resumed = app_server
.resume_thread(
app.config.clone(),
child_thread_ids[1],
app.resume_model_settings(),
)
.await?;
assert!(resumed.blocks_direct_input);
app.replace_chat_widget_with_app_server_thread(
&mut tui,
&mut app_server,
resumed,
crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await?;
while app_event_rx.try_recv().is_ok() {}
app.chat_widget
.restore_user_message_to_composer("direct resume stays view-only".into());
let draft = app.chat_widget.composer_text_with_pending();
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
assert!(
!std::iter::from_fn(|| app_event_rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(Op::UserTurn { .. })))
);
Ok(())
})
}
#[test] #[test]
fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> { fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> {
const WORKER_THREADS: usize = 1; const WORKER_THREADS: usize = 1;
@@ -2947,6 +3093,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
cwd: test_path_buf("/tmp/agent").abs(), cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown, source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: Some("Robie".to_string()), agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()), agent_role: Some("explorer".to_string()),
@@ -3042,6 +3189,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
cwd: test_path_buf("/tmp/agent").abs(), cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown, source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: Some("Robie".to_string()), agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()), agent_role: Some("explorer".to_string()),
@@ -3104,6 +3252,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
cwd: test_path_buf("/tmp/read").abs(), cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown, source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -3586,6 +3735,7 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() {
AppServerStartedThread { AppServerStartedThread {
session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")), session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")),
turns: Vec::new(), turns: Vec::new(),
blocks_direct_input: false,
}, },
&mut child_snapshot, &mut child_snapshot,
) )
@@ -5778,11 +5928,13 @@ async fn refreshed_snapshot_session_persists_resumed_turns() {
AppServerStartedThread { AppServerStartedThread {
session: resumed_session.clone(), session: resumed_session.clone(),
turns: resumed_turns.clone(), turns: resumed_turns.clone(),
blocks_direct_input: true,
}, },
&mut snapshot, &mut snapshot,
) )
.await; .await;
assert!(app.agent_navigation.is_parent_owned(thread_id));
assert_eq!(snapshot.session, Some(resumed_session.clone())); assert_eq!(snapshot.session, Some(resumed_session.clone()));
assert_eq!(snapshot.turns, resumed_turns); assert_eq!(snapshot.turns, resumed_turns);
+2
View File
@@ -162,6 +162,7 @@ async fn startup_thread_started_submits_queued_startup_input() {
Ok(AppServerStartedThread { Ok(AppServerStartedThread {
session: test_thread_session(thread_id, test_path_buf("/tmp/project")), session: test_thread_session(thread_id, test_path_buf("/tmp/project")),
turns: Vec::new(), turns: Vec::new(),
blocks_direct_input: false,
}), }),
) )
.await .await
@@ -239,6 +240,7 @@ fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> {
Ok(AppServerStartedThread { Ok(AppServerStartedThread {
session: test_thread_session(stale_thread_id, test_path_buf("/tmp/project")), session: test_thread_session(stale_thread_id, test_path_buf("/tmp/project")),
turns: Vec::new(), turns: Vec::new(),
blocks_direct_input: false,
}), }),
) )
.await?; .await?;
+4 -1
View File
@@ -1245,7 +1245,10 @@ impl App {
started: AppServerStartedThread, started: AppServerStartedThread,
snapshot: &mut ThreadEventSnapshot, snapshot: &mut ThreadEventSnapshot,
) { ) {
let AppServerStartedThread { session, turns } = started; if started.blocks_direct_input {
self.agent_navigation.mark_parent_owned(thread_id);
}
let AppServerStartedThread { session, turns, .. } = started;
if let Some(channel) = self.thread_event_channels.get(&thread_id) { if let Some(channel) = self.thread_event_channels.get(&thread_id) {
let mut store = channel.store.lock().await; let mut store = channel.store.lock().await;
store.set_session(session.clone(), turns.clone()); store.set_session(session.clone(), turns.clone());
@@ -425,6 +425,7 @@ mod tests {
cwd: test_path_buf("/tmp/read").abs(), cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown, source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
+28
View File
@@ -47,6 +47,7 @@ use codex_app_server_protocol::ReviewDelivery;
use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread; use codex_app_server_protocol::Thread;
@@ -119,6 +120,7 @@ use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelServiceTier;
use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::SubAgentSource;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri; use codex_utils_path_uri::PathUri;
use color_eyre::eyre::ContextCompat; use color_eyre::eyre::ContextCompat;
@@ -215,6 +217,24 @@ impl ThreadParamsMode {
pub(crate) struct AppServerStartedThread { pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState, pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>, pub(crate) turns: Vec<Turn>,
pub(crate) blocks_direct_input: bool,
}
pub(crate) fn source_agent_path(source: &SessionSource) -> Option<String> {
match source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
agent_path.clone().map(String::from)
}
_ => None,
}
}
/// Uses the server capability when available and preserves compatibility with older servers.
pub(crate) fn thread_blocks_direct_input(thread: &Thread) -> bool {
thread
.can_accept_direct_input
.map(|can_accept| !can_accept)
.unwrap_or_else(|| source_agent_path(&thread.source).is_some())
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -1568,6 +1588,7 @@ async fn started_thread_from_start_response(
config: &Config, config: &Config,
thread_params_mode: ThreadParamsMode, thread_params_mode: ThreadParamsMode,
) -> Result<AppServerStartedThread> { ) -> Result<AppServerStartedThread> {
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
let session = let session =
thread_session_state_from_thread_start_response(&response, config, thread_params_mode) thread_session_state_from_thread_start_response(&response, config, thread_params_mode)
.await .await
@@ -1575,6 +1596,7 @@ async fn started_thread_from_start_response(
Ok(AppServerStartedThread { Ok(AppServerStartedThread {
session, session,
turns: response.thread.turns, turns: response.thread.turns,
blocks_direct_input,
}) })
} }
@@ -1583,6 +1605,7 @@ async fn started_thread_from_resume_response(
config: &Config, config: &Config,
thread_params_mode: ThreadParamsMode, thread_params_mode: ThreadParamsMode,
) -> Result<AppServerStartedThread> { ) -> Result<AppServerStartedThread> {
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
let session = let session =
thread_session_state_from_thread_resume_response(&response, config, thread_params_mode) thread_session_state_from_thread_resume_response(&response, config, thread_params_mode)
.await .await
@@ -1590,6 +1613,7 @@ async fn started_thread_from_resume_response(
Ok(AppServerStartedThread { Ok(AppServerStartedThread {
session, session,
turns: response.thread.turns, turns: response.thread.turns,
blocks_direct_input,
}) })
} }
@@ -1598,6 +1622,7 @@ async fn started_thread_from_fork_response(
config: &Config, config: &Config,
thread_params_mode: ThreadParamsMode, thread_params_mode: ThreadParamsMode,
) -> Result<AppServerStartedThread> { ) -> Result<AppServerStartedThread> {
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
let session = let session =
thread_session_state_from_thread_fork_response(&response, config, thread_params_mode) thread_session_state_from_thread_fork_response(&response, config, thread_params_mode)
.await .await
@@ -1605,6 +1630,7 @@ async fn started_thread_from_fork_response(
Ok(AppServerStartedThread { Ok(AppServerStartedThread {
session, session,
turns: response.thread.turns, turns: response.thread.turns,
blocks_direct_input,
}) })
} }
@@ -2493,6 +2519,7 @@ mod tests {
cwd: test_path_buf("/tmp/project").abs(), cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(), cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli, source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -2568,6 +2595,7 @@ mod tests {
assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.session.permission_profile, read_only_profile);
assert_eq!(started.turns.len(), 1); assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]); assert_eq!(started.turns[0], response.thread.turns[0]);
assert!(!started.blocks_direct_input);
let embedded_config = ConfigBuilder::default() let embedded_config = ConfigBuilder::default()
.codex_home(temp_dir.path().join("embedded-codex-home")) .codex_home(temp_dir.path().join("embedded-codex-home"))
+209 -75
View File
@@ -80,6 +80,13 @@
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so //! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
//! pasted content and text elements are preserved when extracting args. //! pasted content and text elements are preserved when extracting args.
//! //!
//! # Parent-Owned Thread Mode
//!
//! Parent-owned subagent threads keep the draft editable while blocking agent-directed submission.
//! On the `Enter` and `Tab` submission paths, normal prompts, disallowed slash commands, and `!`
//! shell commands return `ParentOwnedInputBlocked` without clearing the draft. Bare local and
//! navigation slash commands remain available so users can leave or manage the view.
//!
//! # Large Paste Placeholders //! # Large Paste Placeholders
//! //!
//! Large pastes insert an element placeholder in the buffer and store the full text in //! Large pastes insert an element placeholder in the buffer and store the full text in
@@ -320,9 +327,59 @@ pub enum InputResult {
/// command-history entry still represents the original command invocation that should be /// command-history entry still represents the original command invocation that should be
/// committed only if dispatch accepts it. /// committed only if dispatch accepts it.
CommandWithArgs(SlashCommand, String, Vec<TextElement>), CommandWithArgs(SlashCommand, String, Vec<TextElement>),
/// Agent-directed input was attempted while viewing a parent-owned spawned child thread.
ParentOwnedInputBlocked,
None, None,
} }
fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool {
args.is_empty()
&& matches!(
command,
SlashCommand::Feedback
| SlashCommand::New
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::App
| SlashCommand::Side
| SlashCommand::Btw
| SlashCommand::Agent
| SlashCommand::MultiAgents
| SlashCommand::Vim
| SlashCommand::Keymap
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Logout
| SlashCommand::Copy
| SlashCommand::Raw
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Import
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::Ide
| SlashCommand::DebugConfig
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme
| SlashCommand::Pets
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Rollout
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedInputAction { pub enum QueuedInputAction {
Plain, Plain,
@@ -384,6 +441,7 @@ pub(crate) struct ChatComposer {
frame_requester: Option<FrameRequester>, frame_requester: Option<FrameRequester>,
attachments: AttachmentState, attachments: AttachmentState,
placeholder_text: String, placeholder_text: String,
blocks_direct_input: bool,
is_task_running: bool, is_task_running: bool,
queue_submissions: bool, queue_submissions: bool,
/// Slash-command draft staged for local recall after application-level dispatch. /// Slash-command draft staged for local recall after application-level dispatch.
@@ -434,7 +492,7 @@ struct ComposerDraft {
cursor: usize, cursor: usize,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
pub(crate) struct ComposerDraftSnapshot { pub(crate) struct ComposerDraftSnapshot {
pub(crate) text: String, pub(crate) text: String,
pub(crate) text_elements: Vec<TextElement>, pub(crate) text_elements: Vec<TextElement>,
@@ -559,6 +617,7 @@ impl ChatComposer {
frame_requester: None, frame_requester: None,
attachments: AttachmentState::default(), attachments: AttachmentState::default(),
placeholder_text, placeholder_text,
blocks_direct_input: false,
is_task_running: false, is_task_running: false,
queue_submissions: false, queue_submissions: false,
pending_slash_command_history: None, pending_slash_command_history: None,
@@ -1368,6 +1427,11 @@ impl ChatComposer {
self.placeholder_text = placeholder; self.placeholder_text = placeholder;
} }
pub(crate) fn set_parent_owned_thread(&mut self) {
self.blocks_direct_input = true;
self.placeholder_text = "Viewing sub-agent — direct input is disabled".to_string();
}
/// Move the cursor to the end of the current text buffer. /// Move the cursor to the end of the current text buffer.
pub(crate) fn move_cursor_to_end(&mut self) { pub(crate) fn move_cursor_to_end(&mut self) {
self.draft self.draft
@@ -2776,6 +2840,43 @@ impl ChatComposer {
should_queue: bool, should_queue: bool,
now: Instant, now: Instant,
) -> (InputResult, bool) { ) -> (InputResult, bool) {
// Preserve newlines that are part of a paste before applying parent-owned submission
// policy. Queued startup input still flushes the burst into its queued message below.
let in_slash_context = self.slash_commands_enabled()
&& !self.draft.is_bash_mode
&& (matches!(self.popups.active, ActivePopup::Command(_))
|| self
.draft
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
if !should_queue
&& !self.draft.disable_paste_burst
&& self.draft.paste_burst.is_active()
&& !in_slash_context
&& self.draft.paste_burst.append_newline_if_active(now)
{
return (InputResult::None, true);
}
if !should_queue
&& !in_slash_context
&& !self.draft.disable_paste_burst
&& self
.draft
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
self.draft.textarea.insert_str("\n");
self.draft.paste_burst.extend_window(now);
return (InputResult::None, true);
}
if let Some(result) = self.handle_parent_owned_submission() {
return result;
}
if should_queue { if should_queue {
if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted); self.handle_paste(pasted);
@@ -2829,41 +2930,6 @@ impl ChatComposer {
return (result, true); return (result, true);
} }
// If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst
// and accumulate it rather than submitting or inserting immediately.
// Do not treat as paste inside a slash-command context.
let in_slash_context = self.slash_commands_enabled()
&& !self.draft.is_bash_mode
&& (matches!(self.popups.active, ActivePopup::Command(_))
|| self
.draft
.textarea
.text()
.lines()
.next()
.unwrap_or("")
.starts_with('/'));
if !self.draft.disable_paste_burst
&& self.draft.paste_burst.is_active()
&& !in_slash_context
&& self.draft.paste_burst.append_newline_if_active(now)
{
return (InputResult::None, true);
}
// During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit.
if !in_slash_context
&& !self.draft.disable_paste_burst
&& self
.draft
.paste_burst
.newline_should_insert_instead_of_submit(now)
{
self.draft.textarea.insert_str("\n");
self.draft.paste_burst.extend_window(now);
return (InputResult::None, true);
}
let original_input = self.current_text(); let original_input = self.current_text();
let original_text_elements = self.current_text_elements(); let original_text_elements = self.current_text_elements();
let original_mention_bindings = self.snapshot_mention_bindings(); let original_mention_bindings = self.snapshot_mention_bindings();
@@ -2910,6 +2976,27 @@ impl ChatComposer {
} }
} }
fn handle_parent_owned_submission(&mut self) -> Option<(InputResult, bool)> {
if !self.blocks_direct_input {
return None;
}
let text = self.current_text();
let allowed_slash_command = parse_slash_name(&text).is_some_and(|(name, args, _)| {
matches!(
self.slash_input().command(name),
Some(SlashCommandItem::Builtin(command))
if name == command.command()
&& parent_owned_command_is_allowed(command, args)
)
});
if text.starts_with('/') && allowed_slash_command {
return None;
}
Some((InputResult::ParentOwnedInputBlocked, true))
}
/// Check if the first line is a bare slash command (no args) and dispatch it. /// Check if the first line is a bare slash command (no args) and dispatch it.
/// Returns Some(InputResult) if a command was dispatched, None otherwise. /// Returns Some(InputResult) if a command was dispatched, None otherwise.
fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> { fn try_dispatch_bare_slash_command(&mut self) -> Option<InputResult> {
@@ -4565,6 +4652,47 @@ mod tests {
) )
} }
#[test]
fn parent_owned_thread_allows_bare_navigation_commands() {
for (command, expected) in [
("/agent", SlashCommand::Agent),
("/side", SlashCommand::Side),
("/btw", SlashCommand::Btw),
("/diff ", SlashCommand::Diff),
] {
let (mut composer, _rx) = new_test_composer();
composer.set_parent_owned_thread();
composer.set_text_content(command.to_string(), Vec::new(), Vec::new());
assert_eq!(
composer.handle_submission(/*should_queue*/ false).0,
InputResult::Command(expected)
);
}
}
#[test]
fn parent_owned_thread_allows_safe_command_selected_from_prefix() {
let (mut composer, _rx) = new_test_composer();
composer.set_parent_owned_thread();
type_chars_humanlike(&mut composer, &['/', 'a', 'g']);
let result = composer
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.0;
assert_eq!(result, InputResult::Command(SlashCommand::Agent));
}
#[test]
fn parent_owned_thread_placeholder_snapshot() {
snapshot_composer_state(
"parent_owned_thread_placeholder",
/*enhanced_keys_supported*/ false,
ChatComposer::set_parent_owned_thread,
);
}
#[test] #[test]
fn footer_hint_row_is_separated_from_composer() { fn footer_hint_row_is_separated_from_composer() {
let (tx, _rx) = unbounded_channel::<AppEvent>(); let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -7984,54 +8112,51 @@ mod tests {
/// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a
/// newline into the buffered payload and flush as a single paste later. /// newline into the buffered payload and flush as a single paste later.
#[test] #[test]
fn ascii_burst_treats_enter_as_newline() { fn ascii_burst_treats_enter_as_newline_even_when_parent_owned() {
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>(); for parent_owned in [false, true] {
let sender = AppEventSender::new(tx); let (mut composer, _rx) = new_test_composer();
let mut composer = ChatComposer::new( if parent_owned {
/*has_input_focus*/ true, composer.set_parent_owned_thread();
sender, }
/*enhanced_keys_supported*/ false, let mut now = Instant::now();
"Ask Codex to do anything".to_string(), let step = Duration::from_millis(1);
/*disable_paste_burst*/ false,
);
let mut now = Instant::now();
let step = Duration::from_millis(1);
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
now,
);
now += step;
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
now,
);
now += step;
let (result, _) = composer.handle_submission_with_time(/*should_queue*/ false, now);
assert!(
matches!(result, InputResult::None),
"Enter during a burst should insert newline, not submit"
);
for ch in ['t', 'h', 'e', 'r', 'e'] {
now += step;
let _ = composer.handle_input_basic_with_time( let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
now, now,
); );
} now += step;
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
now,
);
now += step;
assert!(composer.draft.textarea.text().is_empty()); let (result, _) =
let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; composer.handle_submission_with_time(/*should_queue*/ false, now);
let flushed = composer.handle_paste_burst_flush(flush_time); assert!(
assert!(flushed, "expected paste burst to flush"); matches!(result, InputResult::None),
assert_eq!(composer.draft.textarea.text(), "hi\nthere"); "Enter during a burst should insert newline, not submit"
);
for ch in ['t', 'h', 'e', 'r', 'e'] {
now += step;
let _ = composer.handle_input_basic_with_time(
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
now,
);
}
assert!(composer.draft.textarea.text().is_empty());
let flush_time = now + PasteBurst::recommended_active_flush_delay() + step;
let flushed = composer.handle_paste_burst_flush(flush_time);
assert!(flushed, "expected paste burst to flush");
assert_eq!(composer.draft.textarea.text(), "hi\nthere");
}
} }
/// Behavior: startup-pending submissions are queued immediately, so Enter should flush any /// Behavior: startup-pending submissions are queued immediately, so Enter should flush any
@@ -8947,6 +9072,9 @@ mod tests {
InputResult::Queued { .. } => { InputResult::Queued { .. } => {
panic!("expected command dispatch, but composer queued literal text") panic!("expected command dispatch, but composer queued literal text")
} }
InputResult::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/init'"), InputResult::None => panic!("expected Command result for '/init'"),
} }
assert!( assert!(
@@ -9454,6 +9582,9 @@ mod tests {
InputResult::Queued { .. } => { InputResult::Queued { .. } => {
panic!("expected command dispatch after Tab completion, got literal queue") panic!("expected command dispatch after Tab completion, got literal queue")
} }
InputResult::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/diff'"), InputResult::None => panic!("expected Command result for '/diff'"),
} }
assert!(composer.draft.textarea.is_empty()); assert!(composer.draft.textarea.is_empty());
@@ -9651,6 +9782,9 @@ mod tests {
InputResult::Queued { .. } => { InputResult::Queued { .. } => {
panic!("expected command dispatch, but composer queued literal text") panic!("expected command dispatch, but composer queued literal text")
} }
InputResult::ParentOwnedInputBlocked => {
panic!("expected command dispatch, but parent-owned input was blocked")
}
InputResult::None => panic!("expected Command result for '/mention'"), InputResult::None => panic!("expected Command result for '/mention'"),
} }
assert!( assert!(
@@ -24,6 +24,7 @@ use super::ActivePopup;
use super::ChatComposer; use super::ChatComposer;
use super::InputResult; use super::InputResult;
use super::QueuedInputAction; use super::QueuedInputAction;
use super::parent_owned_command_is_allowed;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SlashValidation { pub(super) enum SlashValidation {
@@ -186,7 +187,7 @@ impl<'a> SlashInput<'a> {
command_popup command_popup
} }
fn command(&self, name: &str) -> Option<SlashCommandItem> { pub(super) fn command(&self, name: &str) -> Option<SlashCommandItem> {
find_slash_command(name, self.command_flags, self.service_tier_commands) find_slash_command(name, self.command_flags, self.service_tier_commands)
} }
} }
@@ -342,6 +343,19 @@ impl ChatComposer {
.. ..
} => { } => {
if let Some(sel) = popup.selected_item() { if let Some(sel) = popup.selected_item() {
if self.blocks_direct_input {
let command_is_allowed = match &sel {
CommandItem::Builtin(cmd) => {
parse_slash_name(self.draft.textarea.text()).is_some_and(
|(_, args, _)| parent_owned_command_is_allowed(*cmd, args),
)
}
CommandItem::ServiceTier(_) => false,
};
if !command_is_allowed {
return (InputResult::ParentOwnedInputBlocked, true);
}
}
if self if self
.complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args(
&sel, &sel,
+5
View File
@@ -448,6 +448,11 @@ impl BottomPane {
self.request_redraw(); self.request_redraw();
} }
pub(crate) fn set_parent_owned_thread(&mut self) {
self.composer.set_parent_owned_thread();
self.request_redraw();
}
/// Update the key hint shown next to queued messages so it matches the /// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for. /// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option<KeyBinding>) { pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option<KeyBinding>) {
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Viewing sub-agent — direct input is disabled "
" "
" "
" "
" "
" "
" "
" ? for shortcuts 100% context left "
+12
View File
@@ -202,6 +202,8 @@ const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection";
const PET_SELECTION_LOADING_VIEW_ID: &str = "pet-selection-loading"; const PET_SELECTION_LOADING_VIEW_ID: &str = "pet-selection-loading";
const AMBIENT_PET_WRAP_GAP_COLUMNS: u16 = 2; const AMBIENT_PET_WRAP_GAP_COLUMNS: u16 = 2;
const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet.";
const PARENT_OWNED_INPUT_MESSAGE: &str =
"This sub-agent is controlled by its parent. Direct input is disabled.";
/// Choose the keybinding used to edit the most-recently queued message. /// Choose the keybinding used to edit the most-recently queued message.
/// ///
@@ -650,6 +652,7 @@ pub(crate) struct ChatWidget {
thread_name: Option<String>, thread_name: Option<String>,
thread_rename_block_message: Option<String>, thread_rename_block_message: Option<String>,
active_side_conversation: bool, active_side_conversation: bool,
blocks_direct_input: bool,
normal_placeholder_text: String, normal_placeholder_text: String,
side_placeholder_text: String, side_placeholder_text: String,
forked_from: Option<ThreadId>, forked_from: Option<ThreadId>,
@@ -1732,6 +1735,15 @@ impl ChatWidget {
T: Into<AppCommand>, T: Into<AppCommand>,
{ {
let op: AppCommand = op.into(); let op: AppCommand = op.into();
if self.blocks_direct_input
&& matches!(
&op,
AppCommand::UserTurn { .. } | AppCommand::Review { .. } | AppCommand::Compact
)
{
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return false;
}
self.prepare_local_op_submission(&op); self.prepare_local_op_submission(&op);
if op.is_review() && !self.bottom_pane.is_task_running() { if op.is_review() && !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true); self.bottom_pane.set_task_running(/*running*/ true);
@@ -194,6 +194,7 @@ impl ChatWidget {
thread_name: None, thread_name: None,
thread_rename_block_message: None, thread_rename_block_message: None,
active_side_conversation: false, active_side_conversation: false,
blocks_direct_input: false,
normal_placeholder_text: placeholder, normal_placeholder_text: placeholder,
side_placeholder_text: side_placeholder, side_placeholder_text: side_placeholder,
forked_from: None, forked_from: None,
+15
View File
@@ -7,6 +7,11 @@
use super::*; use super::*;
impl ChatWidget { impl ChatWidget {
pub(crate) fn set_parent_owned_thread(&mut self) {
self.blocks_direct_input = true;
self.bottom_pane.set_parent_owned_thread();
}
pub(super) fn handle_composer_input_result( pub(super) fn handle_composer_input_result(
&mut self, &mut self,
input_result: InputResult, input_result: InputResult,
@@ -62,6 +67,9 @@ impl ChatWidget {
InputResult::CommandWithArgs(cmd, args, text_elements) => { InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.handle_slash_command_with_args_dispatch(cmd, args, text_elements); self.handle_slash_command_with_args_dispatch(cmd, args, text_elements);
} }
InputResult::ParentOwnedInputBlocked => {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
}
InputResult::None => {} InputResult::None => {}
} }
if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() { if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() {
@@ -124,6 +132,9 @@ impl ChatWidget {
if self.input_queue.suppress_queue_autosend { if self.input_queue.suppress_queue_autosend {
return false; return false;
} }
if self.blocks_direct_input {
return false;
}
if self.is_user_turn_pending_or_running() { if self.is_user_turn_pending_or_running() {
return false; return false;
} }
@@ -189,6 +200,10 @@ impl ChatWidget {
text: String, text: String,
mut collaboration_mode: CollaborationModeMask, mut collaboration_mode: CollaborationModeMask,
) { ) {
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return;
}
if collaboration_mode.mode == Some(ModeKind::Plan) if collaboration_mode.mode == Some(ModeKind::Plan)
&& let Some(effort) = self.config.plan_mode_reasoning_effort.clone() && let Some(effort) = self.config.plan_mode_reasoning_effort.clone()
{ {
@@ -19,6 +19,12 @@ impl ChatWidget {
if self.elevated_windows_sandbox_setup_required() { if self.elevated_windows_sandbox_setup_required() {
return; return;
} }
if self.blocks_direct_input {
if let Some(user_message) = self.initial_user_message.take() {
self.restore_user_message_to_composer(user_message);
}
return;
}
if let Some(user_message) = self.initial_user_message.take() { if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message); self.submit_user_message(user_message);
} }
+6 -2
View File
@@ -168,8 +168,12 @@ impl ChatWidget {
&& !self.bottom_pane.is_task_running() && !self.bottom_pane.is_task_running()
&& self.bottom_pane.no_modal_or_popup_active() => && self.bottom_pane.no_modal_or_popup_active() =>
{ {
self.cycle_collaboration_mode(); if self.blocks_direct_input {
self.refresh_plan_mode_nudge(); self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
} else {
self.cycle_collaboration_mode();
self.refresh_plan_mode_nudge();
}
} }
_ => { _ => {
let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active(); let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active();
@@ -19,6 +19,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use super::ChatWidget; use super::ChatWidget;
use super::PARENT_OWNED_INPUT_MESSAGE;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::key_hint::KeyBindingListExt; use crate::key_hint::KeyBindingListExt;
@@ -72,6 +73,11 @@ impl ChatWidget {
return false; return false;
} }
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return true;
}
if !self.is_session_configured() { if !self.is_session_configured() {
self.add_info_message( self.add_info_message(
"Reasoning shortcuts are disabled until startup completes.".to_string(), "Reasoning shortcuts are disabled until startup completes.".to_string(),
+3
View File
@@ -23,6 +23,9 @@ impl ChatWidget {
}; };
self.bottom_pane.set_placeholder_text(placeholder); self.bottom_pane.set_placeholder_text(placeholder);
self.bottom_pane.set_side_conversation_active(active); self.bottom_pane.set_side_conversation_active(active);
if self.blocks_direct_input && !active {
self.bottom_pane.set_parent_owned_thread();
}
} }
pub(crate) fn side_conversation_active(&self) -> bool { pub(crate) fn side_conversation_active(&self) -> bool {
@@ -254,6 +254,10 @@ impl ChatWidget {
self.submit_user_message(INIT_PROMPT.to_string().into()); self.submit_user_message(INIT_PROMPT.to_string().into());
} }
SlashCommand::Compact => { SlashCommand::Compact => {
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return;
}
self.clear_token_usage(); self.clear_token_usage();
if !self.bottom_pane.is_task_running() { if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true); self.bottom_pane.set_task_running(/*running*/ true);
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/composer_submission.rs
expression: rendered
---
■ This sub-agent is controlled by its parent. Direct input is disabled.
@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests/composer_submission.rs
expression: rendered
---
■ This sub-agent is controlled by its parent. Direct input is disabled.
■ This sub-agent is controlled by its parent. Direct input is disabled.
■ This sub-agent is controlled by its parent. Direct input is disabled.
@@ -9,6 +9,149 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::collections::VecDeque; use std::collections::VecDeque;
#[tokio::test]
async fn parent_owned_thread_blocks_all_direct_input_entry_points() {
let (mut chat, mut rx, mut op_rx) =
make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await;
chat.thread_id = Some(ThreadId::new());
drain_insert_history(&mut rx);
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
chat.set_parent_owned_thread();
chat.set_side_conversation_active(/*active*/ false);
chat.bottom_pane
.set_composer_text("keep this draft".to_string(), Vec::new(), Vec::new());
let before = chat.bottom_pane.composer_draft_snapshot();
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before);
assert_no_submit_op(&mut op_rx);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.flatten()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!("parent_owned_thread_rejects_input", rendered);
let collaboration_mode_before = chat.active_collaboration_mask.clone();
let plan_mode = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
.expect("expected plan collaboration mode");
chat.submit_user_message_with_mode("Implement the plan.".to_string(), plan_mode);
assert_eq!(chat.active_collaboration_mask, collaboration_mode_before);
assert_no_submit_op(&mut op_rx);
for command in [
"/init",
"/review check this",
"/side inspect this",
"/archive",
"/rename",
"/agent parent",
"/diff now",
"!echo blocked",
" !echo blocked",
] {
chat.bottom_pane
.set_composer_text(command.to_string(), Vec::new(), Vec::new());
let before = chat.bottom_pane.composer_draft_snapshot();
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_draft_snapshot(), before);
assert_no_submit_op(&mut op_rx);
}
assert!(!chat.submit_op(AppCommand::compact()));
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn parent_owned_thread_blocks_settings_shortcuts() {
let (mut chat, mut rx, _op_rx) =
make_chatwidget_manual(/*model_override*/ Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
chat.set_parent_owned_thread();
drain_insert_history(&mut rx);
let collaboration_mode_before = chat.active_collaboration_mask.clone();
let reasoning_effort_before = chat.current_reasoning_effort();
for key_event in [
KeyEvent::from(KeyCode::BackTab),
KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT),
KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT),
] {
chat.handle_key_event(key_event);
}
assert_eq!(chat.active_collaboration_mask, collaboration_mode_before);
assert_eq!(chat.current_reasoning_effort(), reasoning_effort_before);
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(events.iter().all(|event| !matches!(
event,
AppEvent::SubmitThreadOp { .. }
| AppEvent::UpdateModel(_)
| AppEvent::UpdateReasoningEffort(_)
| AppEvent::UpdatePlanModeReasoningEffort(_)
)));
let rendered = events
.into_iter()
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(cell.display_lines(/*width*/ 80)),
_ => None,
})
.flatten()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!("parent_owned_thread_rejects_settings_shortcuts", rendered);
}
#[tokio::test]
async fn parent_owned_thread_restores_pending_initial_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await;
let pending_prompt = "keep this startup prompt".to_string();
chat.initial_user_message = Some(pending_prompt.clone().into());
chat.set_parent_owned_thread();
chat.submit_initial_user_message_if_pending();
assert_eq!(chat.bottom_pane.composer_text(), pending_prompt);
assert!(chat.initial_user_message.is_none());
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn parent_owned_thread_preserves_queued_input_before_draining() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await;
let queued_message = QueuedUserMessage {
user_message: UserMessage::from("keep this queued prompt"),
action: QueuedInputAction::Plain,
pending_pastes: vec![("[Image 1]".to_string(), "pasted contents".to_string())],
};
let history_record = UserMessageHistoryRecord::UserMessageText;
chat.input_queue
.queued_user_messages
.push_back(queued_message.clone());
chat.input_queue
.queued_user_message_history_records
.push_back(history_record.clone());
chat.set_parent_owned_thread();
assert!(!chat.maybe_send_next_queued_input());
assert_eq!(
chat.input_queue.queued_user_messages,
VecDeque::from([queued_message])
);
assert_eq!(
chat.input_queue.queued_user_message_history_records,
VecDeque::from([history_record])
);
assert_no_submit_op(&mut op_rx);
}
#[tokio::test] #[tokio::test]
async fn submission_preserves_text_elements_and_local_images() { async fn submission_preserves_text_elements_and_local_images() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+4
View File
@@ -5737,6 +5737,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"), cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli, source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -5775,6 +5776,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"), cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli, source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -5847,6 +5849,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"), cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli, source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,
@@ -5908,6 +5911,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(), cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"), cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli, source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None, thread_source: None,
agent_nickname: None, agent_nickname: None,
agent_role: None, agent_role: None,