mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +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:
1 parent
315195492c
commit
3f0669dbd6
37 files changed
+781
-99
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user