Skip to content

Commit c042acf

Browse files
committed
feat: flag exec events for user shell commands
1 parent cb6584d commit c042acf

18 files changed

+171
-47
lines changed

codex-rs/core/src/exec.rs

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::io;
66
use std::path::Path;
77
use std::path::PathBuf;
88
use std::process::ExitStatus;
9+
use std::sync::Arc;
910
use std::time::Duration;
1011
use std::time::Instant;
1112

@@ -84,6 +85,45 @@ pub struct StdoutStream {
8485
pub tx_event: Sender<Event>,
8586
}
8687

88+
type DeltaEventFn = dyn Fn(&str, ExecOutputStream, Vec<u8>) -> EventMsg + Send + Sync;
89+
90+
#[derive(Clone)]
91+
pub struct DeltaEventBuilder {
92+
inner: Arc<DeltaEventFn>,
93+
}
94+
95+
impl DeltaEventBuilder {
96+
pub fn exec_command() -> Self {
97+
Self {
98+
inner: Arc::new(|call_id, stream, chunk| {
99+
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
100+
call_id: call_id.to_string(),
101+
stream,
102+
chunk,
103+
is_user_shell_command: false,
104+
})
105+
}),
106+
}
107+
}
108+
109+
pub fn user_command() -> Self {
110+
Self {
111+
inner: Arc::new(|call_id, stream, chunk| {
112+
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
113+
call_id: call_id.to_string(),
114+
stream,
115+
chunk,
116+
is_user_shell_command: true,
117+
})
118+
}),
119+
}
120+
}
121+
122+
pub fn build(&self, call_id: &str, stream: ExecOutputStream, chunk: Vec<u8>) -> EventMsg {
123+
(self.inner)(call_id, stream, chunk)
124+
}
125+
}
126+
87127
pub async fn process_exec_tool_call(
88128
params: ExecParams,
89129
sandbox_type: SandboxType,
@@ -138,6 +178,7 @@ pub(crate) async fn execute_exec_env(
138178
env: ExecEnv,
139179
sandbox_policy: &SandboxPolicy,
140180
stdout_stream: Option<StdoutStream>,
181+
delta_event_builder: Option<DeltaEventBuilder>,
141182
) -> Result<ExecToolCallOutput> {
142183
let ExecEnv {
143184
command,
@@ -161,7 +202,15 @@ pub(crate) async fn execute_exec_env(
161202
};
162203

163204
let start = Instant::now();
164-
let raw_output_result = exec(params, sandbox, sandbox_policy, stdout_stream).await;
205+
let delta_event_builder = delta_event_builder.unwrap_or_else(DeltaEventBuilder::exec_command);
206+
let raw_output_result = exec(
207+
params,
208+
sandbox,
209+
sandbox_policy,
210+
stdout_stream,
211+
delta_event_builder.clone(),
212+
)
213+
.await;
165214
let duration = start.elapsed();
166215
finalize_exec_result(raw_output_result, sandbox, duration)
167216
}
@@ -434,6 +483,7 @@ async fn exec(
434483
sandbox: SandboxType,
435484
sandbox_policy: &SandboxPolicy,
436485
stdout_stream: Option<StdoutStream>,
486+
delta_event_builder: DeltaEventBuilder,
437487
) -> Result<RawExecToolCallOutput> {
438488
#[cfg(target_os = "windows")]
439489
if sandbox == SandboxType::WindowsRestrictedToken {
@@ -465,7 +515,7 @@ async fn exec(
465515
env,
466516
)
467517
.await?;
468-
consume_truncated_output(child, timeout, stdout_stream).await
518+
consume_truncated_output(child, timeout, stdout_stream, delta_event_builder).await
469519
}
470520

471521
/// Consumes the output of a child process, truncating it so it is suitable for
@@ -474,6 +524,7 @@ async fn consume_truncated_output(
474524
mut child: Child,
475525
timeout: Duration,
476526
stdout_stream: Option<StdoutStream>,
527+
delta_event_builder: DeltaEventBuilder,
477528
) -> Result<RawExecToolCallOutput> {
478529
// Both stdout and stderr were configured with `Stdio::piped()`
479530
// above, therefore `take()` should normally return `Some`. If it doesn't
@@ -497,12 +548,14 @@ async fn consume_truncated_output(
497548
stdout_stream.clone(),
498549
false,
499550
Some(agg_tx.clone()),
551+
delta_event_builder.clone(),
500552
));
501553
let stderr_handle = tokio::spawn(read_capped(
502554
BufReader::new(stderr_reader),
503555
stdout_stream.clone(),
504556
true,
505557
Some(agg_tx.clone()),
558+
delta_event_builder.clone(),
506559
));
507560

508561
let (exit_status, timed_out) = tokio::select! {
@@ -554,6 +607,7 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
554607
stream: Option<StdoutStream>,
555608
is_stderr: bool,
556609
aggregate_tx: Option<Sender<Vec<u8>>>,
610+
delta_event_builder: DeltaEventBuilder,
557611
) -> io::Result<StreamOutput<Vec<u8>>> {
558612
let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
559613
let mut tmp = [0u8; READ_CHUNK_SIZE];
@@ -571,15 +625,15 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
571625
&& emitted_deltas < MAX_EXEC_OUTPUT_DELTAS_PER_CALL
572626
{
573627
let chunk = tmp[..n].to_vec();
574-
let msg = EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
575-
call_id: stream.call_id.clone(),
576-
stream: if is_stderr {
628+
let msg = delta_event_builder.build(
629+
&stream.call_id,
630+
if is_stderr {
577631
ExecOutputStream::Stderr
578632
} else {
579633
ExecOutputStream::Stdout
580634
},
581635
chunk,
582-
});
636+
);
583637
let event = Event {
584638
id: stream.sub_id.clone(),
585639
msg,

codex-rs/core/src/sandboxing/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,5 +165,5 @@ pub async fn execute_env(
165165
policy: &SandboxPolicy,
166166
stdout_stream: Option<StdoutStream>,
167167
) -> crate::error::Result<ExecToolCallOutput> {
168-
execute_exec_env(env.clone(), policy, stdout_stream).await
168+
execute_exec_env(env.clone(), policy, stdout_stream, None).await
169169
}

codex-rs/core/src/tools/events.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ async fn emit_exec_end(
337337
exit_code,
338338
duration,
339339
formatted_output,
340+
is_user_shell_command: false,
340341
}),
341342
)
342343
.await;

codex-rs/core/src/tools/handlers/unified_exec.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ impl ToolHandler for UnifiedExecHandler {
157157
call_id: response.event_call_id.clone(),
158158
stream: ExecOutputStream::Stdout,
159159
chunk: response.output.as_bytes().to_vec(),
160+
is_user_shell_command: false,
160161
};
161162
session
162163
.send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta))

codex-rs/exec/tests/event_processor_with_json_output.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ fn exec_command_end_success_produces_completed_command_item() {
656656
exit_code: 0,
657657
duration: Duration::from_millis(5),
658658
formatted_output: String::new(),
659+
is_user_shell_command: false,
659660
}),
660661
);
661662
let out_ok = ep.collect_thread_events(&end_ok);
@@ -716,6 +717,7 @@ fn exec_command_end_failure_produces_failed_command_item() {
716717
exit_code: 1,
717718
duration: Duration::from_millis(2),
718719
formatted_output: String::new(),
720+
is_user_shell_command: false,
719721
}),
720722
);
721723
let out_fail = ep.collect_thread_events(&end_fail);
@@ -750,6 +752,7 @@ fn exec_command_end_without_begin_is_ignored() {
750752
exit_code: 0,
751753
duration: Duration::from_millis(1),
752754
formatted_output: String::new(),
755+
is_user_shell_command: false,
753756
}),
754757
);
755758
let out = ep.collect_thread_events(&end_only);

codex-rs/protocol/src/protocol.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,10 @@ pub struct ExecCommandEndEvent {
12361236
pub duration: Duration,
12371237
/// Formatted output from the command, as seen by the model.
12381238
pub formatted_output: String,
1239+
/// True when this exec was initiated directly by the user (e.g. bang command),
1240+
/// not by the agent/model. Defaults to false for backwards compatibility.
1241+
#[serde(default)]
1242+
pub is_user_shell_command: bool,
12391243
}
12401244

12411245
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -1265,6 +1269,10 @@ pub struct ExecCommandOutputDeltaEvent {
12651269
#[schemars(with = "String")]
12661270
#[ts(type = "string")]
12671271
pub chunk: Vec<u8>,
1272+
/// True when this exec was initiated directly by the user (e.g. bang command),
1273+
/// not by the agent/model. Defaults to false for backwards compatibility.
1274+
#[serde(default)]
1275+
pub is_user_shell_command: bool,
12681276
}
12691277

12701278
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -1546,10 +1554,11 @@ mod tests {
15461554
call_id: "call21".to_string(),
15471555
stream: ExecOutputStream::Stdout,
15481556
chunk: vec![1, 2, 3, 4, 5],
1557+
is_user_shell_command: false,
15491558
};
15501559
let serialized = serde_json::to_string(&event)?;
15511560
assert_eq!(
1552-
r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU="}"#,
1561+
r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU=","is_user_shell_command":false}"#,
15531562
serialized,
15541563
);
15551564

codex-rs/tui/src/chatwidget.rs

Lines changed: 71 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -785,11 +785,23 @@ impl ChatWidget {
785785
self.request_redraw();
786786
}
787787

788-
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
789-
let running = self.running_commands.remove(&ev.call_id);
788+
fn handle_command_end_internal(
789+
&mut self,
790+
call_id: String,
791+
aggregated_output: String,
792+
formatted_output: String,
793+
exit_code: i32,
794+
duration: std::time::Duration,
795+
default_is_user_shell_command: bool,
796+
) {
797+
let running = self.running_commands.remove(&call_id);
790798
let (command, parsed, is_user_shell_command) = match running {
791799
Some(rc) => (rc.command, rc.parsed_cmd, rc.is_user_shell_command),
792-
None => (vec![ev.call_id.clone()], Vec::new(), false),
800+
None => (
801+
vec![call_id.clone()],
802+
Vec::new(),
803+
default_is_user_shell_command,
804+
),
793805
};
794806

795807
let needs_new = self
@@ -800,7 +812,7 @@ impl ChatWidget {
800812
if needs_new {
801813
self.flush_active_cell();
802814
self.active_cell = Some(Box::new(new_active_exec_command(
803-
ev.call_id.clone(),
815+
call_id.clone(),
804816
command,
805817
parsed,
806818
is_user_shell_command,
@@ -813,20 +825,40 @@ impl ChatWidget {
813825
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
814826
{
815827
cell.complete_call(
816-
&ev.call_id,
828+
&call_id,
817829
CommandOutput {
818-
exit_code: ev.exit_code,
819-
formatted_output: ev.formatted_output.clone(),
820-
aggregated_output: ev.aggregated_output.clone(),
830+
exit_code,
831+
formatted_output,
832+
aggregated_output,
821833
},
822-
ev.duration,
834+
duration,
823835
);
824836
if cell.should_flush() {
825837
self.flush_active_cell();
826838
}
827839
}
828840
}
829841

842+
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
843+
let ExecCommandEndEvent {
844+
call_id,
845+
aggregated_output,
846+
formatted_output,
847+
exit_code,
848+
duration,
849+
is_user_shell_command,
850+
..
851+
} = ev;
852+
self.handle_command_end_internal(
853+
call_id,
854+
aggregated_output,
855+
formatted_output,
856+
exit_code,
857+
duration,
858+
is_user_shell_command,
859+
);
860+
}
861+
830862
pub(crate) fn handle_patch_apply_end_now(
831863
&mut self,
832864
event: codex_core::protocol::PatchApplyEndEvent,
@@ -875,42 +907,58 @@ impl ChatWidget {
875907
});
876908
}
877909

878-
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
879-
// Ensure the status indicator is visible while the command runs.
910+
fn handle_command_begin_internal(
911+
&mut self,
912+
call_id: String,
913+
command: Vec<String>,
914+
parsed_cmd: Vec<ParsedCommand>,
915+
is_user_shell_command: bool,
916+
) {
880917
self.running_commands.insert(
881-
ev.call_id.clone(),
918+
call_id.clone(),
882919
RunningCommand {
883-
command: ev.command.clone(),
884-
parsed_cmd: ev.parsed_cmd.clone(),
885-
is_user_shell_command: ev.is_user_shell_command,
920+
command: command.clone(),
921+
parsed_cmd: parsed_cmd.clone(),
922+
is_user_shell_command,
886923
},
887924
);
888925
if let Some(cell) = self
889926
.active_cell
890927
.as_mut()
891928
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
892929
&& let Some(new_exec) = cell.with_added_call(
893-
ev.call_id.clone(),
894-
ev.command.clone(),
895-
ev.parsed_cmd.clone(),
896-
ev.is_user_shell_command,
930+
call_id.clone(),
931+
command.clone(),
932+
parsed_cmd.clone(),
933+
is_user_shell_command,
897934
)
898935
{
899936
*cell = new_exec;
900937
} else {
901938
self.flush_active_cell();
902939

903940
self.active_cell = Some(Box::new(new_active_exec_command(
904-
ev.call_id.clone(),
905-
ev.command.clone(),
906-
ev.parsed_cmd,
907-
ev.is_user_shell_command,
941+
call_id,
942+
command,
943+
parsed_cmd,
944+
is_user_shell_command,
908945
)));
909946
}
910947

911948
self.request_redraw();
912949
}
913950

951+
pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
952+
let ExecCommandBeginEvent {
953+
call_id,
954+
command,
955+
parsed_cmd,
956+
is_user_shell_command,
957+
..
958+
} = ev;
959+
self.handle_command_begin_internal(call_id, command, parsed_cmd, is_user_shell_command);
960+
}
961+
914962
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
915963
self.flush_answer_stream_with_separator();
916964
self.flush_active_cell();

codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ expression: "lines[start_idx..].join(\"\\n\")"
2626
through crates for heavy dependencies in Cargo.toml, including cli, core,
2727
exec, linux-sandbox, tui, login, ollama, and mcp.
2828

29-
Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
29+
Ran: for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
3030
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama
3131
tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
3232
│ … +1 lines

codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
source: tui/src/chatwidget/tests.rs
33
expression: exec_blob
44
---
5-
Ran sleep 1
5+
Ran: sleep 1
66
└ (no output)

0 commit comments

Comments
 (0)