mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
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:
@@ -662,6 +662,9 @@ impl App {
|
||||
AppEvent::OpenThreadGoalMenu { thread_id } => {
|
||||
self.open_thread_goal_menu(app_server, thread_id).await;
|
||||
}
|
||||
AppEvent::OpenThreadGoalEditor { thread_id } => {
|
||||
self.open_thread_goal_editor(app_server, thread_id).await;
|
||||
}
|
||||
AppEvent::SetThreadGoalObjective {
|
||||
thread_id,
|
||||
objective,
|
||||
|
||||
@@ -69,6 +69,38 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn open_thread_goal_editor(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: Option<ThreadId>,
|
||||
) {
|
||||
let Some(thread_id) = thread_id else {
|
||||
self.show_no_thread_goal_to_edit();
|
||||
return;
|
||||
};
|
||||
|
||||
let result = app_server.thread_goal_get(thread_id).await;
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let response = match result {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to read thread goal: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(goal) = response.goal else {
|
||||
self.show_no_thread_goal_to_edit();
|
||||
return;
|
||||
};
|
||||
|
||||
self.chat_widget.show_goal_edit_prompt(thread_id, goal);
|
||||
}
|
||||
|
||||
pub(super) async fn set_thread_goal_objective(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
@@ -76,7 +108,7 @@ impl App {
|
||||
objective: String,
|
||||
mode: ThreadGoalSetMode,
|
||||
) {
|
||||
if mode == ThreadGoalSetMode::ConfirmIfExists {
|
||||
if matches!(mode, ThreadGoalSetMode::ConfirmIfExists) {
|
||||
let result = app_server.thread_goal_get(thread_id).await;
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
@@ -96,13 +128,32 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
let replacing_goal = matches!(mode, ThreadGoalSetMode::ReplaceExisting);
|
||||
if replacing_goal {
|
||||
let result = app_server.thread_goal_clear(thread_id).await;
|
||||
|
||||
if let Err(err) = result {
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
}
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to replace thread goal: {err}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let (status, token_budget) = match mode {
|
||||
ThreadGoalSetMode::ConfirmIfExists | ThreadGoalSetMode::ReplaceExisting => {
|
||||
(ThreadGoalStatus::Active, None)
|
||||
}
|
||||
ThreadGoalSetMode::UpdateExisting {
|
||||
status,
|
||||
token_budget,
|
||||
} => (status, Some(token_budget)),
|
||||
};
|
||||
|
||||
let result = app_server
|
||||
.thread_goal_set(
|
||||
thread_id,
|
||||
Some(objective),
|
||||
Some(ThreadGoalStatus::Active),
|
||||
/*token_budget*/ None,
|
||||
)
|
||||
.thread_goal_set(thread_id, Some(objective), Some(status), token_budget)
|
||||
.await;
|
||||
if self.current_displayed_thread_id() != Some(thread_id) {
|
||||
return;
|
||||
@@ -113,9 +164,11 @@ impl App {
|
||||
format!("Goal {}", goal_status_label(response.goal.status)),
|
||||
Some(goal_usage_summary(&response.goal)),
|
||||
),
|
||||
Err(err) => self
|
||||
.chat_widget
|
||||
.add_error_message(format!("Failed to set thread goal: {err}")),
|
||||
Err(err) => {
|
||||
let action = if replacing_goal { "replace" } else { "set" };
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to {action} thread goal: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,4 +261,13 @@ impl App {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn show_no_thread_goal_to_edit(&mut self) {
|
||||
self.chat_widget
|
||||
.add_error_message("No goal is currently set.".to_string());
|
||||
self.chat_widget.add_info_message(
|
||||
"Usage: /goal <objective>".to_string(),
|
||||
Some("Create a goal before editing it.".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ pub(crate) enum RealtimeAudioDeviceKind {
|
||||
pub(crate) enum ThreadGoalSetMode {
|
||||
ConfirmIfExists,
|
||||
ReplaceExisting,
|
||||
UpdateExisting {
|
||||
status: ThreadGoalStatus,
|
||||
token_budget: Option<i64>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -244,6 +248,11 @@ pub(crate) enum AppEvent {
|
||||
thread_id: ThreadId,
|
||||
},
|
||||
|
||||
/// Open an editor for the current thread goal objective.
|
||||
OpenThreadGoalEditor {
|
||||
thread_id: Option<ThreadId>,
|
||||
},
|
||||
|
||||
/// Set or replace the current thread goal objective.
|
||||
SetThreadGoalObjective {
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -9,6 +9,29 @@ impl ChatWidget {
|
||||
self.add_plain_history_lines(goal_summary_lines(&goal));
|
||||
}
|
||||
|
||||
pub(crate) fn show_goal_edit_prompt(&mut self, thread_id: ThreadId, goal: AppThreadGoal) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let status = edited_goal_status(goal.status);
|
||||
let token_budget = goal.token_budget;
|
||||
let view = CustomPromptView::new(
|
||||
"Edit goal".to_string(),
|
||||
"Type a goal objective and press Enter".to_string(),
|
||||
goal.objective,
|
||||
/*context_label*/ None,
|
||||
Box::new(move |objective: String| {
|
||||
tx.send(AppEvent::SetThreadGoalObjective {
|
||||
thread_id,
|
||||
objective,
|
||||
mode: crate::app_event::ThreadGoalSetMode::UpdateExisting {
|
||||
status,
|
||||
token_budget,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
pub(crate) fn show_resume_paused_goal_prompt(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
@@ -79,10 +102,10 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec<Line<'static>> {
|
||||
]));
|
||||
}
|
||||
let command_hint = match goal.status {
|
||||
AppThreadGoalStatus::Active => "Commands: /goal pause, /goal clear",
|
||||
AppThreadGoalStatus::Paused => "Commands: /goal resume, /goal clear",
|
||||
AppThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear",
|
||||
AppThreadGoalStatus::Paused => "Commands: /goal edit, /goal resume, /goal clear",
|
||||
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
|
||||
"Commands: /goal clear"
|
||||
"Commands: /goal edit, /goal clear"
|
||||
}
|
||||
};
|
||||
lines.push(Line::default());
|
||||
@@ -98,3 +121,13 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str {
|
||||
AppThreadGoalStatus::Complete => "complete",
|
||||
}
|
||||
}
|
||||
|
||||
fn edited_goal_status(status: AppThreadGoalStatus) -> AppThreadGoalStatus {
|
||||
match status {
|
||||
AppThreadGoalStatus::Active => AppThreadGoalStatus::Active,
|
||||
AppThreadGoalStatus::Paused => AppThreadGoalStatus::Paused,
|
||||
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
|
||||
AppThreadGoalStatus::Active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,6 +662,15 @@ impl ChatWidget {
|
||||
}
|
||||
let control_command = match trimmed.to_ascii_lowercase().as_str() {
|
||||
"clear" => Some(GoalControlCommand::Clear),
|
||||
"edit" => {
|
||||
self.app_event_tx.send(AppEvent::OpenThreadGoalEditor {
|
||||
thread_id: self.thread_id,
|
||||
});
|
||||
if source == SlashCommandDispatchSource::Live {
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
return;
|
||||
}
|
||||
"pause" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Paused)),
|
||||
"resume" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Active)),
|
||||
_ => None,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/goal_menu.rs
|
||||
expression: "render_bottom_popup(&chat, 100)"
|
||||
---
|
||||
▌ Edit goal
|
||||
▌
|
||||
▌ Keep improving the bare goal command until it feels calm and useful.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -9,4 +9,4 @@ Time used: 1m
|
||||
Tokens used: 12.5K
|
||||
Token budget: 80K
|
||||
|
||||
Commands: /goal pause, /goal clear
|
||||
Commands: /goal edit, /goal pause, /goal clear
|
||||
|
||||
@@ -9,4 +9,4 @@ Time used: 1m
|
||||
Tokens used: 12.5K
|
||||
Token budget: 80K
|
||||
|
||||
Commands: /goal clear
|
||||
Commands: /goal edit, /goal clear
|
||||
|
||||
@@ -8,4 +8,4 @@ Objective: Keep improving the bare goal command until it feels calm and useful.
|
||||
Time used: 1m
|
||||
Tokens used: 12.5K
|
||||
|
||||
Commands: /goal resume, /goal clear
|
||||
Commands: /goal edit, /goal resume, /goal clear
|
||||
|
||||
@@ -58,6 +58,103 @@ async fn resume_paused_goal_prompt_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -747,6 +747,27 @@ async fn goal_control_slash_commands_emit_goal_events() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn goal_edit_slash_command_opens_goal_editor() {
|
||||
for thread_id in [Some(ThreadId::new()), None] {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
|
||||
chat.thread_id = thread_id;
|
||||
|
||||
submit_composer_text(&mut chat, "/goal edit");
|
||||
|
||||
let event = rx.try_recv().expect("expected goal editor event");
|
||||
let AppEvent::OpenThreadGoalEditor {
|
||||
thread_id: actual_thread_id,
|
||||
} = event
|
||||
else {
|
||||
panic!("expected OpenThreadGoalEditor, got {event:?}");
|
||||
};
|
||||
assert_eq!(actual_thread_id, thread_id);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user