mirror of
https://github.com/openai/codex.git
synced 2026-11-01 16:17:58 +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:
@@ -133,7 +133,7 @@ Example with notification opt-out:
|
||||
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
|
||||
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
|
||||
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
|
||||
- `thread/goal/set` — create, replace, or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. Supplying a new `objective` replaces the goal and resets usage accounting. Supplying the current non-terminal objective or omitting `objective` updates the existing goal’s status and/or token budget while preserving usage.
|
||||
- `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`.
|
||||
- `thread/goal/get` — fetch the current persisted goal for a materialized thread; returns `goal: null` when no goal exists.
|
||||
- `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes.
|
||||
- `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal.
|
||||
@@ -481,7 +481,7 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back
|
||||
|
||||
### Example: Set and update a thread goal
|
||||
|
||||
Use `thread/goal/set` with an `objective` to create or replace the current goal for a materialized thread. Supplying a new objective resets `tokensUsed`, `timeUsedSeconds`, and `createdAt`. Supplying the current non-terminal objective, or omitting `objective`, updates the existing goal’s status or token budget while preserving usage history. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget.
|
||||
Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget.
|
||||
|
||||
```json
|
||||
{ "method": "thread/goal/set", "id": 27, "params": {
|
||||
|
||||
@@ -153,15 +153,13 @@ impl ThreadGoalRequestProcessor {
|
||||
.get_thread_goal(thread_id)
|
||||
.await
|
||||
.map_err(|err| invalid_request(err.to_string()))?;
|
||||
if let Some(goal) = existing_goal.as_ref().filter(|goal| {
|
||||
goal.objective == objective
|
||||
&& goal.status != codex_state::ThreadGoalStatus::Complete
|
||||
}) {
|
||||
let previous_status = ExternalGoalPreviousStatus::Existing(goal.status);
|
||||
if let Some(goal) = existing_goal.as_ref() {
|
||||
let previous_status = ExternalGoalPreviousStatus::from(goal);
|
||||
state_db
|
||||
.update_thread_goal(
|
||||
thread_id,
|
||||
codex_state::ThreadGoalUpdate {
|
||||
objective: Some(objective.to_string()),
|
||||
status,
|
||||
token_budget: params.token_budget,
|
||||
expected_goal_id: Some(goal.goal_id.clone()),
|
||||
@@ -198,11 +196,12 @@ impl ThreadGoalRequestProcessor {
|
||||
"cannot update goal for thread {thread_id}: no goal exists"
|
||||
)));
|
||||
};
|
||||
let previous_status = ExternalGoalPreviousStatus::Existing(existing_goal.status);
|
||||
let previous_status = ExternalGoalPreviousStatus::from(&existing_goal);
|
||||
state_db
|
||||
.update_thread_goal(
|
||||
thread_id,
|
||||
codex_state::ThreadGoalUpdate {
|
||||
objective: None,
|
||||
status,
|
||||
token_budget: params.token_budget,
|
||||
expected_goal_id: None,
|
||||
|
||||
@@ -617,6 +617,102 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
config.replace("personality = true\n", "personality = true\ngoals = true\n"),
|
||||
)?;
|
||||
let thread_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"materialized thread",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let goal_id = mcp
|
||||
.send_raw_request(
|
||||
"thread/goal/set",
|
||||
Some(json!({
|
||||
"threadId": thread_id,
|
||||
"objective": "keep polishing",
|
||||
"status": "active",
|
||||
"tokenBudget": 40,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let goal_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(goal_id)),
|
||||
)
|
||||
.await??;
|
||||
let goal: ThreadGoalSetResponse = to_response(goal_resp)?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("thread/goal/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let state_db =
|
||||
StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?;
|
||||
let thread_id = ThreadId::from_string(&thread_id)?;
|
||||
let persisted_goal = state_db
|
||||
.get_thread_goal(thread_id)
|
||||
.await?
|
||||
.expect("goal should exist");
|
||||
state_db
|
||||
.account_thread_goal_usage(
|
||||
thread_id,
|
||||
/*time_delta_seconds*/ 12,
|
||||
/*token_delta*/ 50,
|
||||
codex_state::ThreadGoalAccountingMode::ActiveOnly,
|
||||
Some(persisted_goal.goal_id.as_str()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let edit_id = mcp
|
||||
.send_raw_request(
|
||||
"thread/goal/set",
|
||||
Some(json!({
|
||||
"threadId": thread_id.to_string(),
|
||||
"objective": "keep polishing with clearer wording",
|
||||
"status": "active",
|
||||
"tokenBudget": 40,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
let edit_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(edit_id)),
|
||||
)
|
||||
.await??;
|
||||
let edit: ThreadGoalSetResponse = to_response(edit_resp)?;
|
||||
let updated_goal = state_db
|
||||
.get_thread_goal(thread_id)
|
||||
.await?
|
||||
.expect("goal should still exist");
|
||||
|
||||
assert_eq!(persisted_goal.goal_id, updated_goal.goal_id);
|
||||
assert_eq!(edit.goal.objective, "keep polishing with clearer wording");
|
||||
assert_eq!(edit.goal.status, ThreadGoalStatus::BudgetLimited);
|
||||
assert_eq!(edit.goal.token_budget, Some(40));
|
||||
assert_eq!(edit.goal.tokens_used, 50);
|
||||
assert_eq!(edit.goal.time_used_seconds, 12);
|
||||
assert_eq!(edit.goal.created_at, goal.goal.created_at);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
Reference in New Issue
Block a user