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
2026-07-17 15:14:47 +00:00
committed by copyberry
parent 315195492c
commit 3f0669dbd6
37 changed files with 781 additions and 99 deletions
@@ -212,6 +212,7 @@ fn sample_thread_with_metadata(
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source,
can_accept_direct_input: None,
thread_source,
agent_nickname: None,
agent_role: None,
+1
View File
@@ -470,6 +470,7 @@ fn sample_thread(thread_id: &str) -> Thread {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source: AppServerSessionSource::Exec,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -2639,6 +2639,7 @@ mod tests {
cwd: cwd.clone(),
cli_version: "0.0.0".to_string(),
source: v2::SessionSource::Exec,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -2692,6 +2693,7 @@ mod tests {
"cwd": absolute_path_string("tmp"),
"cliVersion": "0.0.0",
"source": "exec",
"canAcceptDirectInput": null,
"threadSource": null,
"agentNickname": null,
"agentRole": null,
@@ -195,6 +195,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
cwd: absolute_path("tmp"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Exec,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -208,6 +208,10 @@ pub struct Thread {
pub cli_version: String,
/// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
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.
pub thread_source: Option<ThreadSource>,
/// 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/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/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/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`.
@@ -1,4 +1,5 @@
use super::thread_fork_goal::inherit_thread_goal_snapshot;
use super::turn_processor::can_accept_direct_input;
use super::*;
use crate::error_code::method_not_found;
use codex_app_server_protocol::SelectedCapabilityRoot;
@@ -1281,6 +1282,7 @@ impl ThreadRequestProcessor {
let mut thread = build_thread_from_snapshot(
thread_id,
session_configured.session_id.to_string(),
thread.multi_agent_version(),
&config_snapshot,
session_configured.rollout_path.clone(),
);
@@ -2297,8 +2299,12 @@ impl ThreadRequestProcessor {
.load_persisted_thread_for_read(thread_id, include_turns)
.await?
{
// Persisted metadata-only read: no live thread state is needed.
thread
if let Some(loaded_thread) = loaded_thread.as_ref() {
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() {
// Loaded metadata-only read before persistence is materialized: build
// the response from the live thread snapshot.
@@ -2426,6 +2432,7 @@ impl ThreadRequestProcessor {
}
thread.session_id.clone_from(&fallback_thread.session_id);
thread.ephemeral = fallback_thread.ephemeral;
thread.can_accept_direct_input = fallback_thread.can_accept_direct_input;
thread
} else {
fallback_thread
@@ -2803,6 +2810,7 @@ impl ThreadRequestProcessor {
let loaded_thread = build_thread_from_snapshot(
thread_id,
thread.session_configured().session_id.to_string(),
thread.multi_agent_version(),
&config_snapshot,
thread.rollout_path(),
);
@@ -3383,6 +3391,10 @@ impl ThreadRequestProcessor {
);
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.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 listener_command_tx = {
@@ -3594,6 +3606,10 @@ impl ThreadRequestProcessor {
) -> std::result::Result<Thread, String> {
let config_snapshot = thread.config_snapshot().await;
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 {
InitialHistory::Resumed(resumed) => {
let fallback_provider = config_snapshot.model_provider_id.as_str();
@@ -3656,6 +3672,7 @@ impl ThreadRequestProcessor {
let mut thread = build_thread_from_snapshot(
thread_id,
session_id.clone(),
thread.multi_agent_version(),
&config_snapshot,
Some(rollout_path.into()),
);
@@ -3667,6 +3684,7 @@ impl ThreadRequestProcessor {
)),
};
let mut thread = thread?;
thread.can_accept_direct_input = Some(can_accept_direct_input);
thread.id = thread_id.to_string();
thread.session_id = session_id;
thread.path = Some(rollout_path.to_path_buf());
@@ -3926,6 +3944,8 @@ impl ThreadRequestProcessor {
"thread",
);
let config_snapshot = forked_thread.config_snapshot().await;
// Persistent forks materialize their own rollout immediately. Ephemeral forks stay
// pathless, so they rebuild their visible history from the copied source history instead.
let mut thread = if session_configured.rollout_path.is_some() {
@@ -3938,10 +3958,10 @@ impl ThreadRequestProcessor {
include_turns,
)
} else {
let config_snapshot = forked_thread.config_snapshot().await;
let mut thread = build_thread_from_snapshot(
thread_id,
session_configured.session_id.to_string(),
forked_thread.multi_agent_version(),
&config_snapshot,
/*path*/ None,
);
@@ -3959,12 +3979,12 @@ impl ThreadRequestProcessor {
if let Some(name) = source_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.thread_source = forked_thread
.config_snapshot()
.await
.thread_source
.map(Into::into);
thread.thread_source = config_snapshot.thread_source.clone().map(Into::into);
self.thread_watch_manager
.upsert_thread_silently(thread.clone())
@@ -3976,7 +3996,6 @@ impl ThreadRequestProcessor {
.await,
/*has_in_progress_turn*/ false,
);
let config_snapshot = forked_thread.config_snapshot().await;
let sandbox = config_snapshot.sandbox_policy().into();
let 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_role: source.get_agent_role(),
source: source.into(),
can_accept_direct_input: None,
thread_source: thread.thread_source.map(Into::into),
git_info,
name: thread.name,
@@ -4927,6 +4947,7 @@ fn permission_profile_trusts_project(
fn build_thread_from_snapshot(
thread_id: ThreadId,
session_id: String,
multi_agent_version: Option<codex_protocol::protocol::MultiAgentVersion>,
config_snapshot: &ThreadConfigSnapshot,
path: Option<PathBuf>,
) -> Thread {
@@ -4951,6 +4972,10 @@ fn build_thread_from_snapshot(
agent_nickname: config_snapshot.session_source.get_nickname(),
agent_role: config_snapshot.session_source.get_agent_role(),
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),
git_info: None,
name: None,
@@ -4994,6 +5019,7 @@ fn build_thread_from_loaded_snapshot(
build_thread_from_snapshot(
thread_id,
loaded_thread.session_configured().session_id.to_string(),
loaded_thread.multi_agent_version(),
config_snapshot,
loaded_thread.rollout_path(),
)
@@ -204,6 +204,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -319,6 +319,7 @@ pub(crate) fn summary_to_thread(
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
source: source.into(),
can_accept_direct_input: None,
thread_source: None,
git_info,
name: None,
@@ -18,6 +18,18 @@ use crate::image_url::is_remote_image_url;
const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str =
"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> {
if input.iter().any(|item| {
matches!(
@@ -326,12 +338,11 @@ impl TurnRequestProcessor {
request_id: &ConnectionRequestId,
thread: &CodexThread,
) -> Result<(), JSONRPCErrorError> {
if thread.multi_agent_version() == Some(MultiAgentVersion::V2)
&& matches!(
thread.config_snapshot().await.session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
)
{
let config_snapshot = thread.config_snapshot().await;
if !can_accept_direct_input(
thread.multi_agent_version(),
&config_snapshot.session_source,
) {
let error = invalid_request(DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR);
self.track_error_response(request_id, &error, /*error_type*/ None);
return Err(error);
+1
View File
@@ -907,6 +907,7 @@ mod tests {
agent_nickname: None,
agent_role: None,
source,
can_accept_direct_input: None,
thread_source: None,
git_info: 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(),
cli_version: "0.0.0-test".to_string(),
source: codex_app_server_protocol::SessionSource::Exec,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -812,6 +813,7 @@ fn sample_thread_start_response() -> ThreadStartResponse {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: Some(codex_app_server_protocol::ThreadSource::User),
agent_nickname: 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();
if let Some(started) = initial_started_thread {
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)
.await?;
if should_prompt_for_paused_goal_after_startup_resume {
+29
View File
@@ -6,6 +6,7 @@
//!
//! Responsibilities here are intentionally narrow:
//! - 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?"
//! - 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 ratatui::text::Span;
use std::collections::HashMap;
use std::collections::HashSet;
/// Small state container for multi-agent picker ordering and labeling.
///
@@ -42,6 +44,8 @@ pub(crate) struct AgentNavigationState {
threads: HashMap<ThreadId, AgentPickerThreadEntry>,
/// Stable first-seen traversal order for picker rows and keyboard cycling.
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.
@@ -64,6 +68,15 @@ impl AgentNavigationState {
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.
///
/// 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) {
self.threads.clear();
self.order.clear();
self.parent_owned_threads.clear();
}
/// 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) {
self.threads.remove(&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.
@@ -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]
fn adjacent_thread_id_wraps_in_spawn_order() {
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
//! 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::Thread;
use codex_protocol::ThreadId;
@@ -29,6 +30,7 @@ pub(crate) struct LoadedSubagentThread {
pub(crate) agent_nickname: Option<String>,
pub(crate) agent_role: 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.
@@ -84,6 +86,7 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
threads_by_id
.remove(&thread_id)
.map(|thread| LoadedSubagentThread {
blocks_direct_input: thread_blocks_direct_input(&thread),
thread_id,
agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role,
@@ -144,6 +147,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -191,6 +195,7 @@ mod tests {
);
child.agent_nickname = Some("Scout".to_string());
child.agent_role = Some("explorer".to_string());
child.can_accept_direct_input = Some(true);
let mut grandchild = test_thread(
grandchild_thread_id,
@@ -198,7 +203,7 @@ mod tests {
);
grandchild.agent_nickname = Some("Atlas".to_string());
grandchild.agent_role = Some("worker".to_string());
grandchild.can_accept_direct_input = Some(false);
let unrelated_child = test_thread(
unrelated_child_id,
thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "researcher"),
@@ -218,12 +223,14 @@ mod tests {
loaded,
vec![
LoadedSubagentThread {
blocks_direct_input: false,
thread_id: child_thread_id,
agent_nickname: Some("Scout".to_string()),
agent_role: Some("explorer".to_string()),
agent_path: None,
},
LoadedSubagentThread {
blocks_direct_input: true,
thread_id: grandchild_thread_id,
agent_nickname: Some("Atlas".to_string()),
agent_role: Some("worker".to_string()),
+33 -3
View File
@@ -5,6 +5,8 @@
//! cache used for multi-agent navigation.
use super::*;
use crate::app_server_session::source_agent_path;
use crate::app_server_session::thread_blocks_direct_input;
#[derive(Clone, Copy)]
pub(super) enum ThreadAttachPresentation {
@@ -184,6 +186,12 @@ impl App {
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.
///
/// Closing a thread is not the same as removing it: users can still inspect finished agent
@@ -205,6 +213,8 @@ impl App {
.await
{
Ok(thread) => {
let is_parent_owned = thread_blocks_direct_input(&thread);
let agent_path = source_agent_path(&thread.source);
let is_running = matches!(
thread.status,
codex_app_server_protocol::ThreadStatus::Active { .. }
@@ -227,6 +237,10 @@ impl App {
}),
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);
true
}
@@ -278,7 +292,12 @@ impl App {
.resume_thread(self.config.clone(), thread_id, self.resume_model_settings())
.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) => {
tracing::warn!(
thread_id = %thread_id,
@@ -369,7 +388,6 @@ impl App {
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(());
}
let mut is_replay_only = self
.agent_navigation
.get(&thread_id)
@@ -398,7 +416,6 @@ impl App {
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(());
}
let previous_thread_id = self.active_thread_id;
self.store_active_thread_receiver().await;
self.active_thread_id = None;
@@ -419,6 +436,7 @@ impl App {
&mut snapshot,
)
.await;
let blocks_direct_input = self.agent_navigation.is_parent_owned(thread_id);
self.active_thread_id = Some(thread_id);
self.active_thread_rx = Some(receiver);
@@ -429,6 +447,9 @@ impl App {
/*initial_user_message*/ None,
);
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.replay_thread_snapshot(snapshot, !is_replay_only);
@@ -519,6 +540,9 @@ impl App {
.set_queue_submissions_until_session_configured(/*queue*/ false);
match result {
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)
.await?;
self.chat_widget.maybe_send_next_queued_input();
@@ -625,6 +649,9 @@ impl App {
initial_user_message,
);
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(
started.session,
started.turns,
@@ -694,6 +721,9 @@ impl App {
for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) {
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(
thread.thread_id,
thread.agent_nickname,
+152
View File
@@ -99,7 +99,11 @@ use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
use codex_protocol::protocol::MultiAgentVersion;
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::TurnStartedEvent;
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(),
is_running_hint: true,
});
assert!(!app.agent_navigation.is_parent_owned(thread_id));
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]
fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> {
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(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: Some("Robie".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(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: Some("Robie".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(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -3586,6 +3735,7 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() {
AppServerStartedThread {
session: test_thread_session(child_thread_id, test_path_buf("/tmp/child")),
turns: Vec::new(),
blocks_direct_input: false,
},
&mut child_snapshot,
)
@@ -5778,11 +5928,13 @@ async fn refreshed_snapshot_session_persists_resumed_turns() {
AppServerStartedThread {
session: resumed_session.clone(),
turns: resumed_turns.clone(),
blocks_direct_input: true,
},
&mut snapshot,
)
.await;
assert!(app.agent_navigation.is_parent_owned(thread_id));
assert_eq!(snapshot.session, Some(resumed_session.clone()));
assert_eq!(snapshot.turns, resumed_turns);
+2
View File
@@ -162,6 +162,7 @@ async fn startup_thread_started_submits_queued_startup_input() {
Ok(AppServerStartedThread {
session: test_thread_session(thread_id, test_path_buf("/tmp/project")),
turns: Vec::new(),
blocks_direct_input: false,
}),
)
.await
@@ -239,6 +240,7 @@ fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> {
Ok(AppServerStartedThread {
session: test_thread_session(stale_thread_id, test_path_buf("/tmp/project")),
turns: Vec::new(),
blocks_direct_input: false,
}),
)
.await?;
+4 -1
View File
@@ -1245,7 +1245,10 @@ impl App {
started: AppServerStartedThread,
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) {
let mut store = channel.store.lock().await;
store.set_session(session.clone(), turns.clone());
@@ -425,6 +425,7 @@ mod tests {
cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: 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::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
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::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::SubAgentSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use color_eyre::eyre::ContextCompat;
@@ -215,6 +217,24 @@ impl ThreadParamsMode {
pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState,
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)]
@@ -1568,6 +1588,7 @@ async fn started_thread_from_start_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<AppServerStartedThread> {
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
let session =
thread_session_state_from_thread_start_response(&response, config, thread_params_mode)
.await
@@ -1575,6 +1596,7 @@ async fn started_thread_from_start_response(
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
blocks_direct_input,
})
}
@@ -1583,6 +1605,7 @@ async fn started_thread_from_resume_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<AppServerStartedThread> {
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
let session =
thread_session_state_from_thread_resume_response(&response, config, thread_params_mode)
.await
@@ -1590,6 +1613,7 @@ async fn started_thread_from_resume_response(
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
blocks_direct_input,
})
}
@@ -1598,6 +1622,7 @@ async fn started_thread_from_fork_response(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Result<AppServerStartedThread> {
let blocks_direct_input = thread_blocks_direct_input(&response.thread);
let session =
thread_session_state_from_thread_fork_response(&response, config, thread_params_mode)
.await
@@ -1605,6 +1630,7 @@ async fn started_thread_from_fork_response(
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
blocks_direct_input,
})
}
@@ -2493,6 +2519,7 @@ mod tests {
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -2568,6 +2595,7 @@ mod tests {
assert_eq!(started.session.permission_profile, read_only_profile);
assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]);
assert!(!started.blocks_direct_input);
let embedded_config = ConfigBuilder::default()
.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
//! 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 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
/// committed only if dispatch accepts it.
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
/// Agent-directed input was attempted while viewing a parent-owned spawned child thread.
ParentOwnedInputBlocked,
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)]
pub enum QueuedInputAction {
Plain,
@@ -384,6 +441,7 @@ pub(crate) struct ChatComposer {
frame_requester: Option<FrameRequester>,
attachments: AttachmentState,
placeholder_text: String,
blocks_direct_input: bool,
is_task_running: bool,
queue_submissions: bool,
/// Slash-command draft staged for local recall after application-level dispatch.
@@ -434,7 +492,7 @@ struct ComposerDraft {
cursor: usize,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ComposerDraftSnapshot {
pub(crate) text: String,
pub(crate) text_elements: Vec<TextElement>,
@@ -559,6 +617,7 @@ impl ChatComposer {
frame_requester: None,
attachments: AttachmentState::default(),
placeholder_text,
blocks_direct_input: false,
is_task_running: false,
queue_submissions: false,
pending_slash_command_history: None,
@@ -1368,6 +1427,11 @@ impl ChatComposer {
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.
pub(crate) fn move_cursor_to_end(&mut self) {
self.draft
@@ -2776,6 +2840,43 @@ impl ChatComposer {
should_queue: bool,
now: Instant,
) -> (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 let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
@@ -2829,41 +2930,6 @@ impl ChatComposer {
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_text_elements = self.current_text_elements();
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.
/// Returns Some(InputResult) if a command was dispatched, None otherwise.
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]
fn footer_hint_row_is_separated_from_composer() {
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
/// newline into the buffered payload and flush as a single paste later.
#[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::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
for parent_owned in [false, true] {
let (mut composer, _rx) = new_test_composer();
if parent_owned {
composer.set_parent_owned_thread();
}
let mut now = Instant::now();
let step = Duration::from_millis(1);
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(
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
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;
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");
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(
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
@@ -8947,6 +9072,9 @@ mod tests {
InputResult::Queued { .. } => {
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'"),
}
assert!(
@@ -9454,6 +9582,9 @@ mod tests {
InputResult::Queued { .. } => {
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'"),
}
assert!(composer.draft.textarea.is_empty());
@@ -9651,6 +9782,9 @@ mod tests {
InputResult::Queued { .. } => {
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'"),
}
assert!(
@@ -24,6 +24,7 @@ use super::ActivePopup;
use super::ChatComposer;
use super::InputResult;
use super::QueuedInputAction;
use super::parent_owned_command_is_allowed;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SlashValidation {
@@ -186,7 +187,7 @@ impl<'a> SlashInput<'a> {
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)
}
}
@@ -342,6 +343,19 @@ impl ChatComposer {
..
} => {
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
.complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args(
&sel,
+5
View File
@@ -448,6 +448,11 @@ impl BottomPane {
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
/// binding that `ChatWidget` actually listens for.
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 AMBIENT_PET_WRAP_GAP_COLUMNS: u16 = 2;
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.
///
@@ -650,6 +652,7 @@ pub(crate) struct ChatWidget {
thread_name: Option<String>,
thread_rename_block_message: Option<String>,
active_side_conversation: bool,
blocks_direct_input: bool,
normal_placeholder_text: String,
side_placeholder_text: String,
forked_from: Option<ThreadId>,
@@ -1732,6 +1735,15 @@ impl ChatWidget {
T: Into<AppCommand>,
{
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);
if op.is_review() && !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
@@ -194,6 +194,7 @@ impl ChatWidget {
thread_name: None,
thread_rename_block_message: None,
active_side_conversation: false,
blocks_direct_input: false,
normal_placeholder_text: placeholder,
side_placeholder_text: side_placeholder,
forked_from: None,
+15
View File
@@ -7,6 +7,11 @@
use super::*;
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(
&mut self,
input_result: InputResult,
@@ -62,6 +67,9 @@ impl ChatWidget {
InputResult::CommandWithArgs(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 => {}
}
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 {
return false;
}
if self.blocks_direct_input {
return false;
}
if self.is_user_turn_pending_or_running() {
return false;
}
@@ -189,6 +200,10 @@ impl ChatWidget {
text: String,
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)
&& let Some(effort) = self.config.plan_mode_reasoning_effort.clone()
{
@@ -19,6 +19,12 @@ impl ChatWidget {
if self.elevated_windows_sandbox_setup_required() {
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() {
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.no_modal_or_popup_active() =>
{
self.cycle_collaboration_mode();
self.refresh_plan_mode_nudge();
if self.blocks_direct_input {
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();
@@ -19,6 +19,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crossterm::event::KeyEvent;
use super::ChatWidget;
use super::PARENT_OWNED_INPUT_MESSAGE;
use crate::app_event::AppEvent;
use crate::key_hint::KeyBindingListExt;
@@ -72,6 +73,11 @@ impl ChatWidget {
return false;
}
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return true;
}
if !self.is_session_configured() {
self.add_info_message(
"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_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 {
@@ -254,6 +254,10 @@ impl ChatWidget {
self.submit_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
return;
}
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
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 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]
async fn submission_preserves_text_elements_and_local_images() {
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(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -5775,6 +5776,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -5847,6 +5849,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -5908,6 +5911,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
can_accept_direct_input: None,
thread_source: None,
agent_nickname: None,
agent_role: None,