Fix goal update and add /goal edit command in TUI (#21954)

## Why

Users have requested the ability to edit a goal's objective after a goal
has been created. This PR exposes a new `/goal edit` command in the TUI
to address this request.

In the process of implementing this, I also noticed an existing bug in
the goal runtime. When a goal's objective is updated through the
`thread/goal/set` app server API, the goal runtime didn't emit a new
steering prompt to tell the agent about the new objective. This PR also
fixes this hole.

## What Changed

- Adds `/goal edit` in the TUI, opening an edit box prefilled with the
current goal objective.
- Keeps active and paused goals in their current state, resets completed
goals to active, keeps budget-limited goals budget-limited, and
preserves the existing token budget.
- Changes the existing `thread/goal/set` behavior so editing an
objective preserves goal accounting instead of resetting it. The older
reset-on-new-objective behavior was left over from before
`thread/goal/clear`; clients that need to reset accounting can now clear
the existing goal and create a new one.
- Reuses the existing goal set API path; this does not add or change
app-server protocol surface area.
- Adds a dedicated goal runtime steering prompt when an externally
persisted goal mutation changes the objective, so active turns receive
the updated objective.

## Validation

- Make sure `/goal edit` returns an error if no goal currently exists
- Make sure `/goal edit` displays an edit box that can be optionally
canceled with no side effects
- Make sure that an edited goal results in a steer so the agent starts
pursuing the new objective
- Make sure the new objective is reflected in the goal if you use
`/goal` to display the goal summary
- Make sure that `/goal edit` doesn't reset the token budget, time/token
accounting on the updated goal
This commit is contained in:
Eric Traut
2026-05-11 10:49:19 -07:00
committed by GitHub
parent 32b1ae7099
commit 1e65b3e0af
18 changed files with 679 additions and 55 deletions
+67 -2
View File
@@ -8074,12 +8074,13 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
.expect("goal should remain persisted");
assert_eq!(70, goal.tokens_used);
let previous_status = goal.status;
let previous_goal = goal.clone();
let goal_id = goal.goal_id.clone();
let updated_goal = state_db
.update_thread_goal(
sess.conversation_id,
codex_state::ThreadGoalUpdate {
objective: None,
status: Some(codex_state::ThreadGoalStatus::Complete),
token_budget: None,
expected_goal_id: Some(goal_id),
@@ -8090,7 +8091,7 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet {
external_set: ExternalGoalSet {
goal: updated_goal,
previous_status: ExternalGoalPreviousStatus::Existing(previous_status),
previous_status: ExternalGoalPreviousStatus::from(&previous_goal),
},
})
.await?;
@@ -8108,6 +8109,70 @@ async fn external_goal_mutation_accounts_active_turn_before_status_change() -> a
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> {
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let state_db = goal_test_state_db(sess.as_ref()).await?;
let old_goal = state_db
.replace_thread_goal(
sess.conversation_id,
"Keep improving the benchmark",
codex_state::ThreadGoalStatus::Active,
/*token_budget*/ Some(10_000),
)
.await?;
let new_goal = state_db
.replace_thread_goal(
sess.conversation_id,
"Write a concise benchmark summary",
codex_state::ThreadGoalStatus::Active,
/*token_budget*/ Some(10_000),
)
.await?;
sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet {
external_set: ExternalGoalSet {
goal: new_goal,
previous_status: ExternalGoalPreviousStatus::from(&old_goal),
},
})
.await?;
let pending_input = sess.get_pending_input().await;
assert!(
pending_input.iter().any(|item| {
matches!(
item,
ResponseInputItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content| matches!(
content,
ContentItem::InputText { text }
if text.starts_with("<goal_context>")
&& text.trim_end().ends_with("</goal_context>")
&& text.contains("The active thread goal objective was edited")
&& text.contains("Write a concise benchmark summary")
))
)
}),
"expected objective-updated steering prompt in pending input: {pending_input:?}"
);
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> {
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;