mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
chore: rework unified exec events (#7775)
This commit is contained in:
committed by
GitHub
Unverified
parent
d1c5db5796
commit
0ad54982ae
@@ -527,6 +527,7 @@ server_notification_definitions! {
|
||||
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
|
||||
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
|
||||
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
||||
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
||||
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
|
||||
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
|
||||
McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification),
|
||||
|
||||
@@ -1457,6 +1457,17 @@ pub struct ReasoningTextDeltaNotification {
|
||||
pub content_index: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct TerminalInteractionNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub process_id: String,
|
||||
pub stdin: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -553,6 +553,10 @@ impl CodexClient {
|
||||
print!("{}", delta.delta);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::TerminalInteraction(delta) => {
|
||||
println!("[stdin sent: {}]", delta.stdin);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
ServerNotification::ItemStarted(payload) => {
|
||||
println!("\n< item started: {:?}", payload.item);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ use codex_app_server_protocol::ReasoningTextDeltaNotification;
|
||||
use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::TerminalInteractionNotification;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadTokenUsage;
|
||||
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
|
||||
@@ -573,6 +574,20 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::TerminalInteraction(terminal_event) => {
|
||||
let item_id = terminal_event.call_id.clone();
|
||||
|
||||
let notification = TerminalInteractionNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id,
|
||||
process_id: terminal_event.process_id,
|
||||
stdin: terminal_event.stdin,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::TerminalInteraction(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ExecCommandEnd(exec_command_end_event) => {
|
||||
let ExecCommandEndEvent {
|
||||
call_id,
|
||||
|
||||
@@ -62,6 +62,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
| EventMsg::WebSearchBegin(_)
|
||||
| EventMsg::WebSearchEnd(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::ExecCommandEnd(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
|
||||
@@ -134,7 +134,6 @@ impl ToolEmitter {
|
||||
command: &[String],
|
||||
cwd: PathBuf,
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
process_id: Option<String>,
|
||||
) -> Self {
|
||||
let parsed_cmd = parse_command(command);
|
||||
@@ -142,7 +141,7 @@ impl ToolEmitter {
|
||||
command: command.to_vec(),
|
||||
cwd,
|
||||
source,
|
||||
interaction_input,
|
||||
interaction_input: None, // TODO(jif) drop this field in the protocol.
|
||||
parsed_cmd,
|
||||
process_id,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::protocol::ExecOutputStream;
|
||||
use crate::protocol::TerminalInteractionEvent;
|
||||
use crate::shell::Shell;
|
||||
use crate::shell::get_shell_by_model_provided_path;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
@@ -189,7 +188,6 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
&command,
|
||||
cwd.clone(),
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
None,
|
||||
Some(process_id.clone()),
|
||||
);
|
||||
emitter.emit(event_ctx, ToolEventStage::Begin).await;
|
||||
@@ -218,7 +216,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
"failed to parse write_stdin arguments: {err:?}"
|
||||
))
|
||||
})?;
|
||||
manager
|
||||
let response = manager
|
||||
.write_stdin(WriteStdinRequest {
|
||||
process_id: &args.session_id.to_string(),
|
||||
input: &args.chars,
|
||||
@@ -228,7 +226,18 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}"))
|
||||
})?
|
||||
})?;
|
||||
|
||||
let interaction = TerminalInteractionEvent {
|
||||
call_id: response.event_call_id.clone(),
|
||||
process_id: args.session_id.to_string(),
|
||||
stdin: args.chars.clone(),
|
||||
};
|
||||
session
|
||||
.send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction))
|
||||
.await;
|
||||
|
||||
response
|
||||
}
|
||||
other => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
@@ -237,18 +246,6 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
}
|
||||
};
|
||||
|
||||
// Emit a delta event with the chunk of output we just produced, if any.
|
||||
if !response.output.is_empty() {
|
||||
let delta = ExecCommandOutputDeltaEvent {
|
||||
call_id: response.event_call_id.clone(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: response.output.as_bytes().to_vec(),
|
||||
};
|
||||
session
|
||||
.send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta))
|
||||
.await;
|
||||
}
|
||||
|
||||
let content = format_response(&response);
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::protocol::ExecOutputStream;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
|
||||
use super::CommandTranscript;
|
||||
use super::UnifiedExecContext;
|
||||
use super::session::UnifiedExecSession;
|
||||
|
||||
/// Spawn a background task that continuously reads from the PTY, appends to the
|
||||
/// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8
|
||||
/// boundaries.
|
||||
pub(crate) fn start_streaming_output(
|
||||
session: &UnifiedExecSession,
|
||||
context: &UnifiedExecContext,
|
||||
transcript: Arc<Mutex<CommandTranscript>>,
|
||||
) {
|
||||
let mut receiver = session.output_receiver();
|
||||
let session_ref = Arc::clone(&context.session);
|
||||
let turn_ref = Arc::clone(&context.turn);
|
||||
let call_id = context.call_id.clone();
|
||||
let cancellation_token = session.cancellation_token();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancellation_token.cancelled() => break,
|
||||
result = receiver.recv() => match result {
|
||||
Ok(chunk) => {
|
||||
pending.extend_from_slice(&chunk);
|
||||
while let Some(prefix) = split_valid_utf8_prefix(&mut pending) {
|
||||
{
|
||||
let mut guard = transcript.lock().await;
|
||||
guard.append(&prefix);
|
||||
}
|
||||
|
||||
let event = ExecCommandOutputDeltaEvent {
|
||||
call_id: call_id.clone(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: prefix,
|
||||
};
|
||||
session_ref
|
||||
.send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn a background watcher that waits for the PTY to exit and then emits a
|
||||
/// single ExecCommandEnd event with the aggregated transcript.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn spawn_exit_watcher(
|
||||
session: Arc<UnifiedExecSession>,
|
||||
session_ref: Arc<Session>,
|
||||
turn_ref: Arc<TurnContext>,
|
||||
call_id: String,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
process_id: String,
|
||||
transcript: Arc<Mutex<CommandTranscript>>,
|
||||
started_at: Instant,
|
||||
) {
|
||||
let exit_token = session.cancellation_token();
|
||||
|
||||
tokio::spawn(async move {
|
||||
exit_token.cancelled().await;
|
||||
|
||||
let exit_code = session.exit_code().unwrap_or(-1);
|
||||
let duration = Instant::now().saturating_duration_since(started_at);
|
||||
emit_exec_end_for_unified_exec(
|
||||
session_ref,
|
||||
turn_ref,
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
Some(process_id),
|
||||
transcript,
|
||||
String::new(),
|
||||
exit_code,
|
||||
duration,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit an ExecCommandEnd event for a unified exec session, using the transcript
|
||||
/// as the primary source of aggregated_output and falling back to the provided
|
||||
/// text when the transcript is empty.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn emit_exec_end_for_unified_exec(
|
||||
session_ref: Arc<Session>,
|
||||
turn_ref: Arc<TurnContext>,
|
||||
call_id: String,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
process_id: Option<String>,
|
||||
transcript: Arc<Mutex<CommandTranscript>>,
|
||||
fallback_output: String,
|
||||
exit_code: i32,
|
||||
duration: Duration,
|
||||
) {
|
||||
let aggregated_output = resolve_aggregated_output(&transcript, fallback_output).await;
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code,
|
||||
stdout: StreamOutput::new(aggregated_output.clone()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new(aggregated_output),
|
||||
duration,
|
||||
timed_out: false,
|
||||
};
|
||||
let event_ctx = ToolEventCtx::new(session_ref.as_ref(), turn_ref.as_ref(), &call_id, None);
|
||||
let emitter = ToolEmitter::unified_exec(
|
||||
&command,
|
||||
cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
process_id,
|
||||
);
|
||||
emitter
|
||||
.emit(event_ctx, ToolEventStage::Success(output))
|
||||
.await;
|
||||
}
|
||||
|
||||
fn split_valid_utf8_prefix(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
if buffer.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let len = buffer.len();
|
||||
let mut split = len;
|
||||
while split > 0 {
|
||||
if std::str::from_utf8(&buffer[..split]).is_ok() {
|
||||
let prefix = buffer[..split].to_vec();
|
||||
buffer.drain(..split);
|
||||
return Some(prefix);
|
||||
}
|
||||
|
||||
if len - split > 4 {
|
||||
break;
|
||||
}
|
||||
split -= 1;
|
||||
}
|
||||
|
||||
// If no valid UTF-8 prefix was found, emit the first byte so the stream
|
||||
// keeps making progress and the transcript reflects all bytes.
|
||||
let byte = buffer.drain(..1).collect();
|
||||
Some(byte)
|
||||
}
|
||||
|
||||
async fn resolve_aggregated_output(
|
||||
transcript: &Arc<Mutex<CommandTranscript>>,
|
||||
fallback: String,
|
||||
) -> String {
|
||||
let guard = transcript.lock().await;
|
||||
if guard.data.is_empty() {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&guard.data).to_string()
|
||||
}
|
||||
@@ -34,6 +34,7 @@ use tokio::sync::Mutex;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
|
||||
mod async_watcher;
|
||||
mod errors;
|
||||
mod session;
|
||||
mod session_manager;
|
||||
@@ -51,6 +52,24 @@ pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64;
|
||||
// Send a warning message to the models when it reaches this number of sessions.
|
||||
pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct CommandTranscript {
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl CommandTranscript {
|
||||
pub fn append(&mut self, bytes: &[u8]) {
|
||||
self.data.extend_from_slice(bytes);
|
||||
if self.data.len() > UNIFIED_EXEC_OUTPUT_MAX_BYTES {
|
||||
let excess = self
|
||||
.data
|
||||
.len()
|
||||
.saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES);
|
||||
self.data.drain(..excess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UnifiedExecContext {
|
||||
pub session: Arc<Session>,
|
||||
pub turn: Arc<TurnContext>,
|
||||
@@ -92,18 +111,14 @@ pub(crate) struct UnifiedExecResponse {
|
||||
pub chunk_id: String,
|
||||
pub wall_time: Duration,
|
||||
pub output: String,
|
||||
/// Raw bytes returned for this unified exec call before any truncation.
|
||||
pub raw_output: Vec<u8>,
|
||||
pub process_id: Option<String>,
|
||||
pub exit_code: Option<i32>,
|
||||
pub original_token_count: Option<usize>,
|
||||
pub session_command: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct UnifiedExecSessionManager {
|
||||
session_store: Mutex<SessionStore>,
|
||||
}
|
||||
|
||||
// Required for mutex sharing.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SessionStore {
|
||||
sessions: HashMap<String, SessionEntry>,
|
||||
@@ -115,22 +130,27 @@ impl SessionStore {
|
||||
self.reserved_sessions_id.remove(session_id);
|
||||
self.sessions.remove(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.reserved_sessions_id.clear();
|
||||
self.sessions.clear();
|
||||
pub(crate) struct UnifiedExecSessionManager {
|
||||
session_store: Mutex<SessionStore>,
|
||||
}
|
||||
|
||||
impl Default for UnifiedExecSessionManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session_store: Mutex::new(SessionStore::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionEntry {
|
||||
session: UnifiedExecSession,
|
||||
session: Arc<UnifiedExecSession>,
|
||||
session_ref: Arc<Session>,
|
||||
turn_ref: Arc<TurnContext>,
|
||||
call_id: String,
|
||||
process_id: String,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
started_at: tokio::time::Instant,
|
||||
last_used: tokio::time::Instant,
|
||||
}
|
||||
|
||||
|
||||
@@ -98,19 +98,22 @@ impl UnifiedExecSession {
|
||||
let cancellation_token_clone = cancellation_token.clone();
|
||||
let output_task = tokio::spawn(async move {
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Ok(chunk) => {
|
||||
let mut guard = buffer_clone.lock().await;
|
||||
guard.push_chunk(chunk);
|
||||
drop(guard);
|
||||
notify_clone.notify_waiters();
|
||||
tokio::select! {
|
||||
_ = cancellation_token_clone.cancelled() => break,
|
||||
result = receiver.recv() => match result {
|
||||
Ok(chunk) => {
|
||||
let mut guard = buffer_clone.lock().await;
|
||||
guard.push_chunk(chunk);
|
||||
drop(guard);
|
||||
notify_clone.notify_waiters();
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
cancellation_token_clone.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
cancellation_token_clone.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,6 +139,14 @@ impl UnifiedExecSession {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver<Vec<u8>> {
|
||||
self.session.output_receiver()
|
||||
}
|
||||
|
||||
pub(super) fn cancellation_token(&self) -> CancellationToken {
|
||||
self.cancellation_token.clone()
|
||||
}
|
||||
|
||||
pub(super) fn has_exited(&self) -> bool {
|
||||
self.session.has_exited()
|
||||
}
|
||||
@@ -144,6 +155,12 @@ impl UnifiedExecSession {
|
||||
self.session.exit_code()
|
||||
}
|
||||
|
||||
pub(super) fn terminate(&self) {
|
||||
self.session.terminate();
|
||||
self.cancellation_token.cancel();
|
||||
self.output_task.abort();
|
||||
}
|
||||
|
||||
async fn snapshot_output(&self) -> Vec<Vec<u8>> {
|
||||
let guard = self.output_buffer.lock().await;
|
||||
guard.snapshot()
|
||||
@@ -246,6 +263,6 @@ impl UnifiedExecSession {
|
||||
|
||||
impl Drop for UnifiedExecSession {
|
||||
fn drop(&mut self) {
|
||||
self.output_task.abort();
|
||||
self.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,12 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::bash::extract_bash_command;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::exec_policy::create_exec_approval_requirement_for_command;
|
||||
use crate::protocol::BackgroundEventEvent;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::sandboxing::ExecEnv;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::orchestrator::ToolOrchestrator;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest;
|
||||
use crate::tools::runtimes::unified_exec::UnifiedExecRuntime;
|
||||
@@ -33,6 +27,7 @@ use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::formatted_truncate_text;
|
||||
|
||||
use super::CommandTranscript;
|
||||
use super::ExecCommandRequest;
|
||||
use super::MAX_UNIFIED_EXEC_SESSIONS;
|
||||
use super::SessionEntry;
|
||||
@@ -43,6 +38,9 @@ use super::UnifiedExecResponse;
|
||||
use super::UnifiedExecSessionManager;
|
||||
use super::WARNING_UNIFIED_EXEC_SESSIONS;
|
||||
use super::WriteStdinRequest;
|
||||
use super::async_watcher::emit_exec_end_for_unified_exec;
|
||||
use super::async_watcher::spawn_exit_watcher;
|
||||
use super::async_watcher::start_streaming_output;
|
||||
use super::clamp_yield_time;
|
||||
use super::generate_chunk_id;
|
||||
use super::resolve_max_tokens;
|
||||
@@ -135,17 +133,23 @@ impl UnifiedExecSessionManager {
|
||||
.await;
|
||||
|
||||
let session = match session {
|
||||
Ok(session) => session,
|
||||
Ok(session) => Arc::new(session),
|
||||
Err(err) => {
|
||||
self.release_process_id(&request.process_id).await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let transcript = Arc::new(tokio::sync::Mutex::new(CommandTranscript::default()));
|
||||
start_streaming_output(&session, context, Arc::clone(&transcript));
|
||||
|
||||
let max_tokens = resolve_max_tokens(request.max_output_tokens);
|
||||
let yield_time_ms = clamp_yield_time(request.yield_time_ms);
|
||||
|
||||
let start = Instant::now();
|
||||
// For the initial exec_command call, we both stream output to events
|
||||
// (via start_streaming_output above) and collect a snapshot here for
|
||||
// the tool response body.
|
||||
let OutputHandles {
|
||||
output_buffer,
|
||||
output_notify,
|
||||
@@ -163,36 +167,44 @@ impl UnifiedExecSessionManager {
|
||||
|
||||
let text = String::from_utf8_lossy(&collected).to_string();
|
||||
let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens));
|
||||
let has_exited = session.has_exited();
|
||||
let exit_code = session.exit_code();
|
||||
let has_exited = session.has_exited() || exit_code.is_some();
|
||||
let chunk_id = generate_chunk_id();
|
||||
let process_id = request.process_id.clone();
|
||||
if has_exited {
|
||||
// Short‑lived command: emit ExecCommandEnd immediately using the
|
||||
// same helper as the background watcher, so all end events share
|
||||
// one implementation.
|
||||
self.release_process_id(&request.process_id).await;
|
||||
let exit = exit_code.unwrap_or(-1);
|
||||
Self::emit_exec_end_from_context(
|
||||
context,
|
||||
&request.command,
|
||||
emit_exec_end_for_unified_exec(
|
||||
Arc::clone(&context.session),
|
||||
Arc::clone(&context.turn),
|
||||
context.call_id.clone(),
|
||||
request.command.clone(),
|
||||
cwd,
|
||||
Some(process_id),
|
||||
Arc::clone(&transcript),
|
||||
output.clone(),
|
||||
exit,
|
||||
wall_time,
|
||||
// We always emit the process ID in order to keep consistency between the Begin
|
||||
// event and the End event.
|
||||
Some(process_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
session.check_for_sandbox_denial_with_text(&text).await?;
|
||||
} else {
|
||||
// Only store session if not exited.
|
||||
// Long‑lived command: persist the session so write_stdin can reuse
|
||||
// it, and register a background watcher that will emit
|
||||
// ExecCommandEnd when the PTY eventually exits (even if no further
|
||||
// tool calls are made).
|
||||
self.store_session(
|
||||
session,
|
||||
Arc::clone(&session),
|
||||
context,
|
||||
&request.command,
|
||||
cwd.clone(),
|
||||
start,
|
||||
process_id,
|
||||
Arc::clone(&transcript),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -205,6 +217,7 @@ impl UnifiedExecSessionManager {
|
||||
chunk_id,
|
||||
wall_time,
|
||||
output,
|
||||
raw_output: collected,
|
||||
process_id: if has_exited {
|
||||
None
|
||||
} else {
|
||||
@@ -238,6 +251,8 @@ impl UnifiedExecSessionManager {
|
||||
|
||||
if !request.input.is_empty() {
|
||||
Self::send_input(&writer_tx, request.input.as_bytes()).await?;
|
||||
// Give the remote process a brief window to react so that we are
|
||||
// more likely to capture its output in the poll below.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
@@ -259,16 +274,20 @@ impl UnifiedExecSessionManager {
|
||||
let original_token_count = approx_token_count(&text);
|
||||
let chunk_id = generate_chunk_id();
|
||||
|
||||
// After polling, refresh_session_state tells us whether the PTY is
|
||||
// still alive or has exited and been removed from the store; we thread
|
||||
// that through so the handler can tag TerminalInteraction with an
|
||||
// appropriate process_id and exit_code.
|
||||
let status = self.refresh_session_state(process_id.as_str()).await;
|
||||
let (process_id, exit_code, completion_entry, event_call_id) = match status {
|
||||
let (process_id, exit_code, event_call_id) = match status {
|
||||
SessionStatus::Alive {
|
||||
exit_code,
|
||||
call_id,
|
||||
process_id,
|
||||
} => (Some(process_id), exit_code, None, call_id),
|
||||
} => (Some(process_id), exit_code, call_id),
|
||||
SessionStatus::Exited { exit_code, entry } => {
|
||||
let call_id = entry.call_id.clone();
|
||||
(None, exit_code, Some(*entry), call_id)
|
||||
(None, exit_code, call_id)
|
||||
}
|
||||
SessionStatus::Unknown => {
|
||||
return Err(UnifiedExecError::UnknownSessionId {
|
||||
@@ -282,6 +301,7 @@ impl UnifiedExecSessionManager {
|
||||
chunk_id,
|
||||
wall_time,
|
||||
output,
|
||||
raw_output: collected,
|
||||
process_id,
|
||||
exit_code,
|
||||
original_token_count: Some(original_token_count),
|
||||
@@ -292,12 +312,6 @@ impl UnifiedExecSessionManager {
|
||||
Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await;
|
||||
}
|
||||
|
||||
if let (Some(exit), Some(entry)) = (response.exit_code, completion_entry) {
|
||||
let total_duration = Instant::now().saturating_duration_since(entry.started_at);
|
||||
Self::emit_exec_end_from_entry(entry, response.output.clone(), exit, total_duration)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -371,28 +385,27 @@ impl UnifiedExecSessionManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn store_session(
|
||||
&self,
|
||||
session: UnifiedExecSession,
|
||||
session: Arc<UnifiedExecSession>,
|
||||
context: &UnifiedExecContext,
|
||||
command: &[String],
|
||||
cwd: PathBuf,
|
||||
started_at: Instant,
|
||||
process_id: String,
|
||||
transcript: Arc<tokio::sync::Mutex<CommandTranscript>>,
|
||||
) {
|
||||
let entry = SessionEntry {
|
||||
session,
|
||||
session: Arc::clone(&session),
|
||||
session_ref: Arc::clone(&context.session),
|
||||
turn_ref: Arc::clone(&context.turn),
|
||||
call_id: context.call_id.clone(),
|
||||
process_id: process_id.clone(),
|
||||
command: command.to_vec(),
|
||||
cwd,
|
||||
started_at,
|
||||
last_used: started_at,
|
||||
};
|
||||
let number_sessions = {
|
||||
let mut store = self.session_store.lock().await;
|
||||
Self::prune_sessions_if_needed(&mut store);
|
||||
store.sessions.insert(process_id, entry);
|
||||
store.sessions.insert(process_id.clone(), entry);
|
||||
store.sessions.len()
|
||||
};
|
||||
|
||||
@@ -405,73 +418,18 @@ impl UnifiedExecSessionManager {
|
||||
)
|
||||
.await;
|
||||
};
|
||||
}
|
||||
|
||||
async fn emit_exec_end_from_entry(
|
||||
entry: SessionEntry,
|
||||
aggregated_output: String,
|
||||
exit_code: i32,
|
||||
duration: Duration,
|
||||
) {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code,
|
||||
stdout: StreamOutput::new(aggregated_output.clone()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new(aggregated_output),
|
||||
duration,
|
||||
timed_out: false,
|
||||
};
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
entry.session_ref.as_ref(),
|
||||
entry.turn_ref.as_ref(),
|
||||
&entry.call_id,
|
||||
None,
|
||||
);
|
||||
let emitter = ToolEmitter::unified_exec(
|
||||
&entry.command,
|
||||
entry.cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
None,
|
||||
Some(entry.process_id.clone()),
|
||||
);
|
||||
emitter
|
||||
.emit(event_ctx, ToolEventStage::Success(output))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn emit_exec_end_from_context(
|
||||
context: &UnifiedExecContext,
|
||||
command: &[String],
|
||||
cwd: PathBuf,
|
||||
aggregated_output: String,
|
||||
exit_code: i32,
|
||||
duration: Duration,
|
||||
process_id: Option<String>,
|
||||
) {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code,
|
||||
stdout: StreamOutput::new(aggregated_output.clone()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new(aggregated_output),
|
||||
duration,
|
||||
timed_out: false,
|
||||
};
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
context.session.as_ref(),
|
||||
context.turn.as_ref(),
|
||||
&context.call_id,
|
||||
None,
|
||||
);
|
||||
let emitter = ToolEmitter::unified_exec(
|
||||
command,
|
||||
spawn_exit_watcher(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&context.session),
|
||||
Arc::clone(&context.turn),
|
||||
context.call_id.clone(),
|
||||
command.to_vec(),
|
||||
cwd,
|
||||
ExecCommandSource::UnifiedExecStartup,
|
||||
None,
|
||||
process_id,
|
||||
transcript,
|
||||
started_at,
|
||||
);
|
||||
emitter
|
||||
.emit(event_ctx, ToolEventStage::Success(output))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn emit_waiting_status(
|
||||
@@ -567,7 +525,7 @@ impl UnifiedExecSessionManager {
|
||||
cancellation_token: &CancellationToken,
|
||||
deadline: Instant,
|
||||
) -> Vec<u8> {
|
||||
const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(25);
|
||||
const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(50);
|
||||
|
||||
let mut collected: Vec<u8> = Vec::with_capacity(4096);
|
||||
let mut exit_signal_received = cancellation_token.is_cancelled();
|
||||
@@ -634,7 +592,9 @@ impl UnifiedExecSessionManager {
|
||||
.collect();
|
||||
|
||||
if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) {
|
||||
store.remove(&session_id);
|
||||
if let Some(entry) = store.remove(&session_id) {
|
||||
entry.session.terminate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -671,8 +631,17 @@ impl UnifiedExecSessionManager {
|
||||
}
|
||||
|
||||
pub(crate) async fn terminate_all_sessions(&self) {
|
||||
let mut sessions = self.session_store.lock().await;
|
||||
sessions.clear();
|
||||
let entries: Vec<SessionEntry> = {
|
||||
let mut sessions = self.session_store.lock().await;
|
||||
let entries: Vec<SessionEntry> =
|
||||
sessions.sessions.drain().map(|(_, entry)| entry).collect();
|
||||
sessions.reserved_sessions_id.clear();
|
||||
entries
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
entry.session.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -648,16 +648,16 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let delta = wait_for_event_match(&codex, |msg| match msg {
|
||||
EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
let event = wait_for_event_match(&codex, |msg| match msg {
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let text = String::from_utf8_lossy(&delta.chunk).to_string();
|
||||
let text = event.stdout;
|
||||
assert!(
|
||||
text.contains("HELLO-UEXEC"),
|
||||
"delta chunk missing expected text: {text:?}"
|
||||
"delta chunk missing expected text: {text:?}",
|
||||
);
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
@@ -665,7 +665,116 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> {
|
||||
async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
skip_if_windows!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.features.enable(Feature::UnifiedExec);
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec-full-lifecycle";
|
||||
let args = json!({
|
||||
"cmd": "printf 'HELLO-FULL-LIFECYCLE'",
|
||||
"yield_time_ms": 50,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "finished"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "exercise full unified exec lifecycle".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut begin_event = None;
|
||||
let mut end_event = None;
|
||||
let mut saw_delta_with_marker = 0;
|
||||
|
||||
loop {
|
||||
let msg = wait_for_event(&codex, |_| true).await;
|
||||
match msg {
|
||||
EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin_event = Some(ev),
|
||||
EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => {
|
||||
let text = String::from_utf8_lossy(&ev.chunk);
|
||||
if text.contains("HELLO-FULL-LIFECYCLE") {
|
||||
saw_delta_with_marker += 1;
|
||||
}
|
||||
}
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => {
|
||||
assert!(
|
||||
end_event.is_none(),
|
||||
"expected a single ExecCommandEnd event for this call id"
|
||||
);
|
||||
end_event = Some(ev);
|
||||
}
|
||||
EventMsg::TaskComplete(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let begin_event = begin_event.expect("expected ExecCommandBegin event");
|
||||
assert_eq!(begin_event.call_id, call_id);
|
||||
assert!(
|
||||
begin_event.process_id.is_some(),
|
||||
"begin event should include a process_id for a long-lived session"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
saw_delta_with_marker, 0,
|
||||
"no ExecCommandOutputDelta should be sent for early exit commands"
|
||||
);
|
||||
|
||||
let end_event = end_event.expect("expected ExecCommandEnd event");
|
||||
assert_eq!(end_event.call_id, call_id);
|
||||
assert_eq!(end_event.exit_code, 0);
|
||||
assert!(
|
||||
end_event.process_id.is_some(),
|
||||
"end event should include process_id emitted by background watcher"
|
||||
);
|
||||
assert!(
|
||||
end_event.aggregated_output.contains("HELLO-FULL-LIFECYCLE"),
|
||||
"aggregated_output should contain the full PTY transcript; got {:?}",
|
||||
end_event.aggregated_output
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
skip_if_windows!(Ok(()));
|
||||
@@ -740,27 +849,210 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Expect a delta event corresponding to the write_stdin call.
|
||||
let delta = wait_for_event_match(&codex, |msg| match msg {
|
||||
EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => {
|
||||
let text = String::from_utf8_lossy(&ev.chunk);
|
||||
if text.contains("WSTDIN-MARK") {
|
||||
Some(ev.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let mut terminal_interaction = None;
|
||||
|
||||
let text = String::from_utf8_lossy(&delta.chunk).to_string();
|
||||
loop {
|
||||
let msg = wait_for_event(&codex, |_| true).await;
|
||||
match msg {
|
||||
EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => {
|
||||
terminal_interaction = Some(ev);
|
||||
}
|
||||
EventMsg::TaskComplete(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let delta = terminal_interaction.expect("expected TerminalInteraction event");
|
||||
assert_eq!(delta.process_id, "1000");
|
||||
let expected_stdin = stdin_args
|
||||
.get("chars")
|
||||
.and_then(Value::as_str)
|
||||
.expect("stdin chars");
|
||||
assert_eq!(delta.stdin, expected_stdin);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
skip_if_windows!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.features.enable(Feature::UnifiedExec);
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let open_call_id = "uexec-delayed-open";
|
||||
let open_args = json!({
|
||||
"cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2",
|
||||
"yield_time_ms": 10,
|
||||
});
|
||||
|
||||
// Poll stdin three times: first for no output, second after the first marker,
|
||||
// and a final long poll to capture the second marker.
|
||||
let first_poll_call_id = "uexec-delayed-poll-1";
|
||||
let first_poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 10,
|
||||
});
|
||||
|
||||
let second_poll_call_id = "uexec-delayed-poll-2";
|
||||
let second_poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 6000,
|
||||
});
|
||||
|
||||
let third_poll_call_id = "uexec-delayed-poll-3";
|
||||
let third_poll_args = json!({
|
||||
"chars": "",
|
||||
"session_id": 1000,
|
||||
"yield_time_ms": 10000,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
open_call_id,
|
||||
"exec_command",
|
||||
&serde_json::to_string(&open_args)?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_function_call(
|
||||
first_poll_call_id,
|
||||
"write_stdin",
|
||||
&serde_json::to_string(&first_poll_args)?,
|
||||
),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_function_call(
|
||||
second_poll_call_id,
|
||||
"write_stdin",
|
||||
&serde_json::to_string(&second_poll_args)?,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-4"),
|
||||
ev_function_call(
|
||||
third_poll_call_id,
|
||||
"write_stdin",
|
||||
&serde_json::to_string(&third_poll_args)?,
|
||||
),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-5"),
|
||||
ev_assistant_message("msg-1", "complete"),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "delayed terminal interaction output".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut begin_event = None;
|
||||
let mut end_event = None;
|
||||
let mut terminal_events = Vec::new();
|
||||
let mut delta_text = String::new();
|
||||
|
||||
// Consume all events for this turn so we can assert on each stage.
|
||||
loop {
|
||||
let msg = wait_for_event(&codex, |_| true).await;
|
||||
match msg {
|
||||
EventMsg::ExecCommandBegin(ev) if ev.call_id == open_call_id => {
|
||||
begin_event = Some(ev);
|
||||
}
|
||||
EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => {
|
||||
delta_text.push_str(&String::from_utf8_lossy(&ev.chunk));
|
||||
}
|
||||
EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => {
|
||||
terminal_events.push(ev);
|
||||
}
|
||||
EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => {
|
||||
end_event = Some(ev);
|
||||
}
|
||||
EventMsg::TaskComplete(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let begin_event = begin_event.expect("expected ExecCommandBegin event");
|
||||
assert!(
|
||||
text.contains("WSTDIN-MARK"),
|
||||
"stdin delta chunk missing expected text: {text:?}"
|
||||
begin_event.process_id.is_some(),
|
||||
"begin event should include process_id for a live session"
|
||||
);
|
||||
|
||||
// We expect three terminal interactions matching the three write_stdin calls.
|
||||
assert_eq!(
|
||||
terminal_events.len(),
|
||||
3,
|
||||
"expected three terminal interactions; got {terminal_events:?}"
|
||||
);
|
||||
|
||||
for event in &terminal_events {
|
||||
assert_eq!(event.call_id, open_call_id);
|
||||
assert_eq!(event.process_id, "1000");
|
||||
}
|
||||
assert_eq!(
|
||||
terminal_events
|
||||
.iter()
|
||||
.map(|ev| ev.stdin.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["", "", ""],
|
||||
"terminal interactions should reflect the three stdin polls"
|
||||
);
|
||||
|
||||
assert!(
|
||||
delta_text.contains("MARKER1") && delta_text.contains("MARKER2"),
|
||||
"streamed deltas should contain both markers; got {delta_text:?}"
|
||||
);
|
||||
|
||||
let end_event = end_event.expect("expected ExecCommandEnd event");
|
||||
assert_eq!(end_event.call_id, open_call_id);
|
||||
assert_eq!(end_event.exit_code, 0);
|
||||
assert!(
|
||||
end_event.process_id.is_some(),
|
||||
"end event should include the process_id"
|
||||
);
|
||||
assert!(
|
||||
end_event.aggregated_output.contains("MARKER1")
|
||||
&& end_event.aggregated_output.contains("MARKER2"),
|
||||
"aggregated output should include both markers in order; got {:?}",
|
||||
end_event.aggregated_output
|
||||
);
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -566,6 +566,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
EventMsg::WebSearchBegin(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
|
||||
@@ -48,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TaskStartedEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
use codex_core::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
@@ -72,6 +73,7 @@ pub struct EventProcessorWithJsonOutput {
|
||||
struct RunningCommand {
|
||||
command: String,
|
||||
item_id: String,
|
||||
aggregated_output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -109,6 +111,10 @@ impl EventProcessorWithJsonOutput {
|
||||
EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev),
|
||||
EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev),
|
||||
EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev),
|
||||
EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev),
|
||||
EventMsg::ExecCommandOutputDelta(ev) => {
|
||||
self.handle_output_chunk(&ev.call_id, &ev.chunk)
|
||||
}
|
||||
EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev),
|
||||
EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev),
|
||||
EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev),
|
||||
@@ -172,6 +178,16 @@ impl EventProcessorWithJsonOutput {
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]
|
||||
}
|
||||
|
||||
fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec<ThreadEvent> {
|
||||
//TODO see how we want to process them
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn handle_terminal_interaction(&mut self, _ev: &TerminalInteractionEvent) -> Vec<ThreadEvent> {
|
||||
//TODO see how we want to process them
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec<ThreadEvent> {
|
||||
let item = ThreadItem {
|
||||
id: self.get_next_item_id(),
|
||||
@@ -214,6 +230,7 @@ impl EventProcessorWithJsonOutput {
|
||||
RunningCommand {
|
||||
command: command_string.clone(),
|
||||
item_id: item_id.clone(),
|
||||
aggregated_output: String::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -366,7 +383,11 @@ impl EventProcessorWithJsonOutput {
|
||||
}
|
||||
|
||||
fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec<ThreadEvent> {
|
||||
let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id)
|
||||
let Some(RunningCommand {
|
||||
command,
|
||||
item_id,
|
||||
aggregated_output,
|
||||
}) = self.running_commands.remove(&ev.call_id)
|
||||
else {
|
||||
warn!(
|
||||
call_id = ev.call_id,
|
||||
@@ -379,12 +400,17 @@ impl EventProcessorWithJsonOutput {
|
||||
} else {
|
||||
CommandExecutionStatus::Failed
|
||||
};
|
||||
let aggregated_output = if ev.aggregated_output.is_empty() {
|
||||
aggregated_output
|
||||
} else {
|
||||
ev.aggregated_output.clone()
|
||||
};
|
||||
let item = ThreadItem {
|
||||
id: item_id,
|
||||
|
||||
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
||||
command,
|
||||
aggregated_output: ev.aggregated_output.clone(),
|
||||
aggregated_output,
|
||||
exit_code: Some(ev.exit_code),
|
||||
status,
|
||||
}),
|
||||
@@ -455,6 +481,21 @@ impl EventProcessorWithJsonOutput {
|
||||
items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item }));
|
||||
}
|
||||
|
||||
if !self.running_commands.is_empty() {
|
||||
for (_, running) in self.running_commands.drain() {
|
||||
let item = ThreadItem {
|
||||
id: running.item_id,
|
||||
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
||||
command: running.command,
|
||||
aggregated_output: running.aggregated_output,
|
||||
exit_code: None,
|
||||
status: CommandExecutionStatus::Completed,
|
||||
}),
|
||||
};
|
||||
items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item }));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = self.last_critical_error.take() {
|
||||
items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error }));
|
||||
} else {
|
||||
|
||||
@@ -48,6 +48,8 @@ use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
|
||||
use codex_protocol::protocol::ExecOutputStream;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ContentBlock;
|
||||
use mcp_types::TextContent;
|
||||
@@ -699,6 +701,93 @@ fn exec_command_end_success_produces_completed_command_item() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_output_delta_updates_item_progress() {
|
||||
let mut ep = EventProcessorWithJsonOutput::new(None);
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo delta".to_string(),
|
||||
];
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let parsed_cmd = Vec::new();
|
||||
|
||||
let begin = event(
|
||||
"d1",
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "delta-1".to_string(),
|
||||
process_id: Some("42".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
}),
|
||||
);
|
||||
let out_begin = ep.collect_thread_events(&begin);
|
||||
assert_eq!(
|
||||
out_begin,
|
||||
vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
||||
item: ThreadItem {
|
||||
id: "item_0".to_string(),
|
||||
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
||||
command: "bash -lc 'echo delta'".to_string(),
|
||||
aggregated_output: String::new(),
|
||||
exit_code: None,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
}),
|
||||
},
|
||||
})]
|
||||
);
|
||||
|
||||
let delta = event(
|
||||
"d2",
|
||||
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
|
||||
call_id: "delta-1".to_string(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: b"partial output\n".to_vec(),
|
||||
}),
|
||||
);
|
||||
let out_delta = ep.collect_thread_events(&delta);
|
||||
assert_eq!(out_delta, Vec::<ThreadEvent>::new());
|
||||
|
||||
let end = event(
|
||||
"d3",
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "delta-1".to_string(),
|
||||
process_id: Some("42".to_string()),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: String::new(),
|
||||
exit_code: 0,
|
||||
duration: Duration::from_millis(3),
|
||||
formatted_output: String::new(),
|
||||
}),
|
||||
);
|
||||
let out_end = ep.collect_thread_events(&end);
|
||||
assert_eq!(
|
||||
out_end,
|
||||
vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
||||
item: ThreadItem {
|
||||
id: "item_0".to_string(),
|
||||
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
||||
command: "bash -lc 'echo delta'".to_string(),
|
||||
aggregated_output: String::new(),
|
||||
exit_code: Some(0),
|
||||
status: CommandExecutionStatus::Completed,
|
||||
}),
|
||||
},
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_end_failure_produces_failed_command_item() {
|
||||
let mut ep = EventProcessorWithJsonOutput::new(None);
|
||||
|
||||
@@ -282,6 +282,7 @@ async fn run_codex_tool_session_inner(
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::ExecCommandEnd(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
|
||||
@@ -518,6 +518,9 @@ pub enum EventMsg {
|
||||
/// Incremental chunk of output from a running command.
|
||||
ExecCommandOutputDelta(ExecCommandOutputDeltaEvent),
|
||||
|
||||
/// Terminal interaction for an in-progress command (stdin sent and stdout observed).
|
||||
TerminalInteraction(TerminalInteractionEvent),
|
||||
|
||||
ExecCommandEnd(ExecCommandEndEvent),
|
||||
|
||||
/// Notification that the agent attached a local image via the view_image tool.
|
||||
@@ -1455,6 +1458,17 @@ pub struct ExecCommandOutputDeltaEvent {
|
||||
pub chunk: Vec<u8>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
|
||||
pub struct TerminalInteractionEvent {
|
||||
/// Identifier for the ExecCommandBegin that produced this chunk.
|
||||
pub call_id: String,
|
||||
/// Process id associated with the running command.
|
||||
pub process_id: String,
|
||||
/// Stdin sent to the running session.
|
||||
pub stdin: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct BackgroundEventEvent {
|
||||
pub message: String,
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_core::protocol::ReviewRequest;
|
||||
use codex_core::protocol::ReviewTarget;
|
||||
use codex_core::protocol::StreamErrorEvent;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use codex_core::protocol::TerminalInteractionEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
@@ -825,6 +826,10 @@ impl ChatWidget {
|
||||
// TODO: Handle streaming exec output if/when implemented
|
||||
}
|
||||
|
||||
fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) {
|
||||
// TODO: Handle once design is ready
|
||||
}
|
||||
|
||||
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
|
||||
self.add_to_history(history_cell::new_patch_event(
|
||||
event.changes,
|
||||
@@ -1022,11 +1027,7 @@ impl ChatWidget {
|
||||
}
|
||||
let (command, parsed, source) = match running {
|
||||
Some(rc) => (rc.command, rc.parsed_cmd, rc.source),
|
||||
None => (
|
||||
vec![ev.call_id.clone()],
|
||||
Vec::new(),
|
||||
ExecCommandSource::Agent,
|
||||
),
|
||||
None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source),
|
||||
};
|
||||
let is_unified_exec_interaction =
|
||||
matches!(source, ExecCommandSource::UnifiedExecInteraction);
|
||||
@@ -1043,7 +1044,7 @@ impl ChatWidget {
|
||||
command,
|
||||
parsed,
|
||||
source,
|
||||
None,
|
||||
ev.interaction_input.clone(),
|
||||
self.config.animations,
|
||||
)));
|
||||
}
|
||||
@@ -1789,6 +1790,7 @@ impl ChatWidget {
|
||||
match msg {
|
||||
EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_) => {}
|
||||
_ => {
|
||||
tracing::trace!("handle_codex_event: {:?}", msg);
|
||||
@@ -1846,6 +1848,7 @@ impl ChatWidget {
|
||||
self.on_elicitation_request(ev);
|
||||
}
|
||||
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
|
||||
EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta),
|
||||
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
|
||||
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
|
||||
EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
|
||||
|
||||
@@ -1175,6 +1175,49 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
assert!(blob.to_lowercase().contains("bloop"), "expected error text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_end_without_begin_uses_event_command() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||
let command = vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo orphaned".to_string(),
|
||||
];
|
||||
let parsed_cmd = codex_core::parse_command::parse_command(&command);
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
chat.handle_codex_event(Event {
|
||||
id: "call-orphan".to_string(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "call-orphan".to_string(),
|
||||
process_id: None,
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: "done".to_string(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: "done".to_string(),
|
||||
exit_code: 0,
|
||||
duration: std::time::Duration::from_millis(5),
|
||||
formatted_output: "done".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
||||
let blob = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
blob.contains("• Ran echo orphaned"),
|
||||
"expected command text to come from event: {blob:?}"
|
||||
);
|
||||
assert!(
|
||||
!blob.contains("call-orphan"),
|
||||
"call id should not be rendered when event has the command: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_shows_unified_exec_startup_commands() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None);
|
||||
|
||||
@@ -94,10 +94,8 @@ impl ExecCommandSession {
|
||||
pub fn exit_code(&self) -> Option<i32> {
|
||||
self.exit_code.lock().ok().and_then(|guard| *guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExecCommandSession {
|
||||
fn drop(&mut self) {
|
||||
pub fn terminate(&self) {
|
||||
if let Ok(mut killer_opt) = self.killer.lock() {
|
||||
if let Some(mut killer) = killer_opt.take() {
|
||||
let _ = killer.kill();
|
||||
@@ -122,6 +120,12 @@ impl Drop for ExecCommandSession {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ExecCommandSession {
|
||||
fn drop(&mut self) {
|
||||
self.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SpawnedPty {
|
||||
pub session: ExecCommandSession,
|
||||
|
||||
Reference in New Issue
Block a user