From 7814ff329cba32e5e44c21c0dcb26a048a236216 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 24 May 2026 02:21:47 -0700 Subject: [PATCH] fix(exec): resume active goals without prompt Fixes #24016. --- codex-rs/exec/src/lib.rs | 108 +++++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 22 deletions(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9061227b6b..54844d52cb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -31,6 +31,9 @@ use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::Thread as AppServerThread; +use codex_app_server_protocol::ThreadGoalGetParams; +use codex_app_server_protocol::ThreadGoalGetResponse; +use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadItem as AppServerThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; @@ -164,6 +167,7 @@ enum InitialOperation { Review { review_request: ReviewRequest, }, + FollowActiveGoal, } enum StdinPromptBehavior { @@ -173,8 +177,7 @@ enum StdinPromptBehavior { /// Always treat stdin as the prompt, used for the explicit `codex exec -` /// sentinel and similar forced-stdin call sites. Forced, - /// If stdin is piped alongside a positional prompt, treat stdin as - /// additional context to append rather than as the primary prompt. + /// Read stdin when it is piped and ignore terminal or empty stdin. OptionalAppend, } @@ -627,25 +630,31 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { } }) .or(root_prompt); - let prompt_text = resolve_prompt(prompt_arg); - let mut items: Vec = imgs - .into_iter() - .chain(args.images.iter().cloned()) - .map(|path| UserInput::LocalImage { path, detail: None }) - .collect(); - items.push(UserInput::Text { - text: prompt_text.clone(), - // CLI input doesn't track UI element ranges, so none are available here. - text_elements: Vec::new(), - }); - let output_schema = load_output_schema(output_schema_path.clone()); - ( - InitialOperation::UserTurn { - items, - output_schema, - }, - prompt_text, - ) + let prompt_text = prompt_arg + .map(|prompt| resolve_prompt(Some(prompt))) + .or_else(|| read_prompt_from_stdin(StdinPromptBehavior::OptionalAppend)); + if let Some(prompt_text) = prompt_text { + let mut items: Vec = imgs + .into_iter() + .chain(args.images.iter().cloned()) + .map(|path| UserInput::LocalImage { path, detail: None }) + .collect(); + items.push(UserInput::Text { + text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. + text_elements: Vec::new(), + }); + let output_schema = load_output_schema(output_schema_path); + ( + InitialOperation::UserTurn { + items, + output_schema, + }, + prompt_text, + ) + } else { + (InitialOperation::FollowActiveGoal, String::new()) + } } (None, root_prompt, imgs) => { let prompt_text = resolve_root_prompt(root_prompt); @@ -709,6 +718,11 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { .map_err(anyhow::Error::msg)?; (session_configured.thread_id, session_configured) } else { + if matches!(initial_operation, InitialOperation::FollowActiveGoal) { + anyhow::bail!( + "No prompt provided and no session was resumed. Specify a prompt or pipe one into stdin." + ); + } let response: ThreadStartResponse = send_request_with_response( &client, ClientRequest::ThreadStart { @@ -741,6 +755,32 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { }; let primary_thread_id_for_span = primary_thread_id.to_string(); + + if matches!(initial_operation, InitialOperation::FollowActiveGoal) { + let response: ThreadGoalGetResponse = send_request_with_response( + &client, + ClientRequest::ThreadGoalGet { + request_id: request_ids.next(), + params: ThreadGoalGetParams { + thread_id: primary_thread_id_for_span.clone(), + }, + }, + "thread/goal/get", + ) + .await + .map_err(|err| { + anyhow::anyhow!( + "No prompt provided and could not verify an active goal for the resumed thread: {err}" + ) + })?; + anyhow::ensure!( + response + .goal + .is_some_and(|goal| goal.status == ThreadGoalStatus::Active), + "No prompt provided and resumed thread has no active goal. Specify a prompt or pipe one into stdin." + ); + } + // Use the start/resume response as the authoritative bootstrap payload. // Waiting for a later streamed `SessionConfigured` event adds up to 10s of // avoidable startup latency on the in-process path. @@ -768,6 +808,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { } }); + let mut error_seen = false; let task_id = match initial_operation { InitialOperation::UserTurn { items, @@ -830,13 +871,36 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { info!("Sent review request with event ID: {task_id}"); task_id } + InitialOperation::FollowActiveGoal => loop { + let Some(server_event) = client.next_event().await else { + anyhow::bail!("resumed active goal did not start a turn"); + }; + match server_event { + InProcessServerEvent::ServerRequest(request) => { + handle_server_request(&client, request, &mut error_seen).await; + } + InProcessServerEvent::ServerNotification(notification) => { + if let ServerNotification::TurnStarted(payload) = ¬ification + && payload.thread_id == primary_thread_id_for_span + { + let task_id = payload.turn.id.clone(); + let _ = event_processor.process_server_notification(notification); + break task_id; + } + } + InProcessServerEvent::Lagged { skipped } => { + let message = lagged_event_warning_message(skipped); + warn!("{message}"); + event_processor.process_warning(message); + } + } + }, }; exec_span.record("turn.id", task_id.as_str()); // Run the loop until the task is complete. // Track whether a fatal error was reported by the server so we can // exit with a non-zero status for automation-friendly signaling. - let mut error_seen = false; let mut interrupt_channel_open = true; let primary_thread_id_for_requests = primary_thread_id.to_string(); loop {