mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add thread/shellCommand to app server API surface (#14988)
This PR adds a new `thread/shellCommand` app server API so clients can implement `!` shell commands. These commands are executed within the sandbox, and the command text and output are visible to the model. The internal implementation mirrors the current TUI `!` behavior. - persist shell command execution as `CommandExecution` thread items, including source and formatted output metadata - bridge live and replayed app-server command execution events back into the existing `tui_app_server` exec rendering path This PR also wires `tui_app_server` to submit `!` commands through the new API.
This commit is contained in:
@@ -2050,6 +2050,12 @@ impl App {
|
||||
app_server.thread_realtime_stop(thread_id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommandView::RunUserShellCommand { command } => {
|
||||
app_server
|
||||
.thread_shell_command(thread_id, command.to_string())
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommandView::OverrideTurnContext { .. } => Ok(true),
|
||||
_ => Ok(false),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,9 @@ pub(crate) enum AppCommandView<'a> {
|
||||
RealtimeConversationAudio(&'a ConversationAudioParams),
|
||||
RealtimeConversationText(&'a ConversationTextParams),
|
||||
RealtimeConversationClose,
|
||||
RunUserShellCommand {
|
||||
command: &'a str,
|
||||
},
|
||||
UserTurn {
|
||||
items: &'a [UserInput],
|
||||
cwd: &'a PathBuf,
|
||||
@@ -134,6 +137,10 @@ impl AppCommand {
|
||||
Self(Op::RealtimeConversationClose)
|
||||
}
|
||||
|
||||
pub(crate) fn run_user_shell_command(command: String) -> Self {
|
||||
Self(Op::RunUserShellCommand { command })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn user_turn(
|
||||
items: Vec<UserInput>,
|
||||
@@ -291,6 +298,7 @@ impl AppCommand {
|
||||
AppCommandView::RealtimeConversationText(params)
|
||||
}
|
||||
Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose,
|
||||
Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command },
|
||||
Op::UserTurn {
|
||||
items,
|
||||
cwd,
|
||||
|
||||
@@ -42,6 +42,8 @@ use codex_app_server_protocol::ThreadRollbackParams;
|
||||
use codex_app_server_protocol::ThreadRollbackResponse;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadSetNameResponse;
|
||||
use codex_app_server_protocol::ThreadShellCommandParams;
|
||||
use codex_app_server_protocol::ThreadShellCommandResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
||||
@@ -492,6 +494,26 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_shell_command(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
command: String,
|
||||
) -> Result<()> {
|
||||
let request_id = self.next_request_id();
|
||||
let _: ThreadShellCommandResponse = self
|
||||
.client
|
||||
.request_typed(ClientRequest::ThreadShellCommand {
|
||||
request_id,
|
||||
params: ThreadShellCommandParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
command,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/shellCommand failed in app-server TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_background_terminals_clean(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -5134,13 +5134,7 @@ impl ChatWidget {
|
||||
)));
|
||||
return;
|
||||
}
|
||||
// TODO: Restore `!` support in app-server TUI once command execution can
|
||||
// persist transcript-visible output into thread history with parity to the
|
||||
// legacy TUI.
|
||||
self.add_to_history(history_cell::new_error_event(
|
||||
"`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history.".to_string(),
|
||||
));
|
||||
self.request_redraw();
|
||||
self.submit_op(AppCommand::run_user_shell_command(cmd.to_string()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5562,6 +5556,7 @@ impl ChatWidget {
|
||||
command,
|
||||
cwd,
|
||||
process_id,
|
||||
source,
|
||||
status,
|
||||
command_actions,
|
||||
aggregated_output,
|
||||
@@ -5582,10 +5577,11 @@ impl ChatWidget {
|
||||
.into_iter()
|
||||
.map(codex_app_server_protocol::CommandAction::into_core)
|
||||
.collect(),
|
||||
source: ExecCommandSource::Agent,
|
||||
source: source.to_core(),
|
||||
interaction_input: None,
|
||||
});
|
||||
} else {
|
||||
let aggregated_output = aggregated_output.unwrap_or_default();
|
||||
self.on_exec_command_end(ExecCommandEndEvent {
|
||||
call_id: id,
|
||||
process_id,
|
||||
@@ -5596,16 +5592,16 @@ impl ChatWidget {
|
||||
.into_iter()
|
||||
.map(codex_app_server_protocol::CommandAction::into_core)
|
||||
.collect(),
|
||||
source: ExecCommandSource::Agent,
|
||||
source: source.to_core(),
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: aggregated_output.unwrap_or_default(),
|
||||
aggregated_output: aggregated_output.clone(),
|
||||
exit_code: exit_code.unwrap_or_default(),
|
||||
duration: Duration::from_millis(
|
||||
duration_ms.unwrap_or_default().max(0) as u64
|
||||
),
|
||||
formatted_output: String::new(),
|
||||
formatted_output: aggregated_output,
|
||||
status: match status {
|
||||
codex_app_server_protocol::CommandExecutionStatus::Completed => {
|
||||
codex_protocol::protocol::ExecCommandStatus::Completed
|
||||
@@ -6144,6 +6140,7 @@ impl ChatWidget {
|
||||
command,
|
||||
cwd,
|
||||
process_id,
|
||||
source,
|
||||
command_actions,
|
||||
..
|
||||
} => {
|
||||
@@ -6157,7 +6154,7 @@ impl ChatWidget {
|
||||
.into_iter()
|
||||
.map(codex_app_server_protocol::CommandAction::into_core)
|
||||
.collect(),
|
||||
source: ExecCommandSource::Agent,
|
||||
source: source.to_core(),
|
||||
interaction_input: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8840,7 +8840,7 @@ async fn user_shell_command_renders_output_not_exploring() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bang_shell_command_is_disabled_in_app_server_tui() {
|
||||
async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
let conversation_id = ThreadId::new();
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
@@ -8873,22 +8873,11 @@ async fn bang_shell_command_is_disabled_in_app_server_tui() {
|
||||
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let mut rendered = None;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
rendered = Some(lines_to_single_string(&cell.display_lines(80)));
|
||||
break;
|
||||
}
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
|
||||
other => panic!("expected RunUserShellCommand op, got {other:?}"),
|
||||
}
|
||||
let rendered = rendered.expect("expected disabled bang-shell error");
|
||||
assert!(
|
||||
rendered.contains(
|
||||
"`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history."
|
||||
),
|
||||
"expected bang-shell disabled message, got: {rendered}"
|
||||
);
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user