mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
+13
@@ -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 "
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+5
@@ -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.
|
||||
+7
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user