Remove no-tool goal continuation suppression (#20523)

## Why

`/goal` is supposed to keep Codex working until the goal is actually
done. The previous continuation logic had two ways to stop early: the
continuation prompt told the model to wait for new input when it felt
blocked, and the runtime suppressed another continuation turn after a
continuation finished without any tool calls.

That made goals stop short even when the agent could still keep making
progress (I received a few reports of this from users). It also relied
on a brittle heuristic that treated "no registry tool calls" as
equivalent to "should stop."

## What changed

- removed the continuation prompt sentence that told the model to stop
and wait for new input when it could not continue productively
- removed the goal runtime suppression heuristic that stopped
auto-continuation after a no-tool continuation turn
- deleted the continuation-activity bookkeeping and left `tool_calls` as
telemetry only
- added focused regressions for the two intended behaviors: completed
no-tool continuation turns still continue, while `request_user_input`
keeps the existing turn open instead of spawning a new continuation
This commit is contained in:
Eric Traut
2026-05-01 09:09:55 -07:00
committed by GitHub
parent 227bee0445
commit 3d1d164aee
5 changed files with 142 additions and 65 deletions
+132 -6
View File
@@ -118,6 +118,8 @@ use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputResponse;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use core_test_support::context_snapshot;
@@ -136,6 +138,7 @@ use core_test_support::test_codex::test_codex;
use core_test_support::test_path_buf;
use core_test_support::tracing::install_test_tracing;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use opentelemetry::trace::TraceContextExt;
use opentelemetry::trace::TraceId;
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
@@ -6958,7 +6961,7 @@ async fn interrupt_accounts_active_goal_before_pausing() -> anyhow::Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Result<()> {
async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config
@@ -6984,17 +6987,21 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_assistant_message("msg-2", "I am still working on the benchmark note."),
ev_completed("resp-3"),
]),
sse(vec![
ev_response_created("resp-4"),
ev_function_call(
"call-complete-goal",
"update_goal",
r#"{"status":"complete"}"#,
),
ev_completed("resp-3"),
ev_completed("resp-4"),
]),
sse(vec![
ev_assistant_message("msg-2", "Goal complete."),
ev_completed("resp-4"),
ev_assistant_message("msg-3", "Goal complete."),
ev_completed("resp-5"),
]),
],
)
@@ -7018,7 +7025,7 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
let event = test.codex.next_event().await?;
if matches!(event.msg, EventMsg::TurnComplete(_)) {
completed_turns += 1;
if completed_turns == 2 {
if completed_turns == 3 {
return anyhow::Ok(());
}
}
@@ -7029,6 +7036,125 @@ async fn active_goal_continuation_runs_to_completion_after_turn() -> anyhow::Res
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> anyhow::Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::Goals)
.expect("goal mode should be enableable in tests");
config
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("default-mode request_user_input should be enableable in tests");
});
let test = builder.build(&server).await?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
"call-create-goal",
"create_goal",
r#"{"objective":"write a benchmark note"}"#,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "Draft ready."),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_function_call(
"call-ask-user",
"request_user_input",
r#"{"questions":[{"header":"Choice","id":"next_step","question":"Pick one","options":[{"label":"Outline","description":"Start with an outline."},{"label":"Draft","description":"Write a full draft."}]}]}"#,
),
ev_completed("resp-3"),
]),
sse(vec![
ev_response_created("resp-4"),
ev_function_call(
"call-complete-goal",
"update_goal",
r#"{"status":"complete"}"#,
),
ev_completed("resp-4"),
]),
sse(vec![
ev_assistant_message("msg-2", "Goal complete."),
ev_completed("resp-5"),
]),
],
)
.await;
test.codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "write a benchmark note".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
let request_user_input_event = wait_for_event_match(&test.codex, |event| match event {
EventMsg::RequestUserInput(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(3, responses.requests().len());
assert!(
timeout(Duration::from_millis(200), test.codex.next_event())
.await
.is_err(),
"waiting for request_user_input should keep the turn open without emitting more events"
);
assert_eq!(
3,
responses.requests().len(),
"waiting for request_user_input should not start another continuation request"
);
test.codex
.submit(Op::UserInputAnswer {
id: request_user_input_event.turn_id,
response: RequestUserInputResponse {
answers: std::collections::HashMap::from([(
"next_step".to_string(),
RequestUserInputAnswer {
answers: vec!["Outline".to_string()],
},
)]),
},
})
.await?;
let mut completed_turns = 0;
timeout(Duration::from_secs(8), async {
loop {
let event = test.codex.next_event().await?;
if matches!(event.msg, EventMsg::TurnComplete(_)) {
completed_turns += 1;
if completed_turns == 1 {
return anyhow::Ok(());
}
}
}
})
.await??;
assert_eq!(5, responses.requests().len());
Ok(())
}
async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) {
let mut state = sess.state.lock().await;
state.set_token_info(Some(TokenUsageInfo {