Files
codex/codex-rs/tui/src/chatwidget/tests/goal_menu.rs
T
Eric Traut 1e65b3e0af 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
2026-05-11 10:49:19 -07:00

219 lines
6.6 KiB
Rust

use super::*;
#[tokio::test]
async fn goal_menu_active_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::Active,
/*token_budget*/ Some(80_000),
));
assert_chatwidget_snapshot!("goal_menu_active", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_paused_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::Paused,
/*token_budget*/ None,
));
assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_budget_limited_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::BudgetLimited,
/*token_budget*/ Some(80_000),
));
assert_chatwidget_snapshot!("goal_menu_budget_limited", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn resume_paused_goal_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_resume_paused_goal_prompt(
thread_id,
"Keep improving the bare goal command until it feels calm and useful.".to_string(),
);
assert_chatwidget_snapshot!(
"resume_paused_goal_prompt",
render_bottom_popup(&chat, /*width*/ 100)
);
}
#[tokio::test]
async fn goal_edit_prompt_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_edit_prompt(
thread_id,
test_goal(
thread_id,
AppThreadGoalStatus::Active,
/*token_budget*/ Some(80_000),
),
);
assert_chatwidget_snapshot!(
"goal_edit_prompt",
render_bottom_popup(&chat, /*width*/ 100)
);
}
#[tokio::test]
async fn goal_edit_prompt_submits_preserved_status_and_budget() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_edit_prompt(
thread_id,
test_goal(
thread_id,
AppThreadGoalStatus::Paused,
/*token_budget*/ Some(80_000),
),
);
chat.handle_paste(" with clearer wording".to_string());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
thread_id: event_thread_id,
objective,
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
},
}) => {
assert_eq!(event_thread_id, thread_id);
assert_eq!(
objective,
"Keep improving the bare goal command until it feels calm and useful. with clearer wording"
);
assert_eq!(status, AppThreadGoalStatus::Paused);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
}
assert!(chat.no_modal_or_popup_active());
}
#[tokio::test]
async fn goal_edit_prompt_resets_terminal_status_to_active() {
let cases = [
AppThreadGoalStatus::BudgetLimited,
AppThreadGoalStatus::Complete,
];
for terminal_status in cases {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_edit_prompt(
thread_id,
test_goal(
thread_id,
terminal_status,
/*token_budget*/ Some(80_000),
),
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
},
..
}) => {
assert_eq!(status, AppThreadGoalStatus::Active);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
}
}
}
#[tokio::test]
async fn resume_paused_goal_prompt_default_resumes_goal() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_resume_paused_goal_prompt(thread_id, "Finish the paused goal.".to_string());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalStatus {
thread_id: event_thread_id,
status,
}) => {
assert_eq!(event_thread_id, thread_id);
assert_eq!(status, AppThreadGoalStatus::Active);
}
other => panic!("expected SetThreadGoalStatus event, got {other:?}"),
}
assert!(chat.no_modal_or_popup_active());
}
#[tokio::test]
async fn resume_paused_goal_prompt_can_leave_goal_paused() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_resume_paused_goal_prompt(thread_id, "Finish the paused goal.".to_string());
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
assert!(chat.no_modal_or_popup_active());
}
fn test_goal(
thread_id: ThreadId,
status: AppThreadGoalStatus,
token_budget: Option<i64>,
) -> AppThreadGoal {
AppThreadGoal {
thread_id: thread_id.to_string(),
objective: "Keep improving the bare goal command until it feels calm and useful."
.to_string(),
status,
token_budget,
tokens_used: 12_500,
time_used_seconds: 90,
created_at: 1_776_272_400,
updated_at: 1_776_272_460,
}
}
fn rendered_goal_summary(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::app_event::AppEvent>,
) -> String {
drain_insert_history(rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n")
}