diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cc9b55567..c16762725 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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": { diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 0e12e44ce..e2acfb173 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -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, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 2b0eafd00..36627ac6e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -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; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index a5f9d5733..4a7a9a3d0 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -70,6 +70,15 @@ static BUDGET_LIMIT_PROMPT_TEMPLATE: LazyLock