diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs index 2c92c0384..6b97a24e9 100644 --- a/codex-rs/ext/goal/src/spec.rs +++ b/codex-rs/ext/goal/src/spec.rs @@ -27,7 +27,7 @@ pub fn create_create_goal_tool() -> ToolSpec { ( "objective".to_string(), JsonSchema::string(Some( - "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." + "Required. The concrete objective to start pursuing. This starts a new active goal when no goal exists or replaces the current goal when it is complete." .to_string(), )), ), @@ -44,7 +44,7 @@ pub fn create_create_goal_tool() -> ToolSpec { name: CREATE_GOAL_TOOL_NAME.to_string(), description: format!( r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. -Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# +Set token_budget only when an explicit token budget is requested. Fails if an unfinished goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# ), strict: false, defer_loading: None, diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index 17947aa34..8c0f51300 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -191,7 +191,7 @@ impl GoalToolExecutor { .map_err(|err| FunctionCallError::RespondToModel(format!("failed to create goal: {err}")))? .ok_or_else(|| { FunctionCallError::RespondToModel( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + "cannot create a new goal because this thread has an unfinished goal; complete the existing goal first" .to_string(), ) })?; diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index f89a08ebb..b922f5332 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -124,7 +124,7 @@ async fn goal_tools_hidden_for_review_subagents() -> anyhow::Result<()> { } #[tokio::test] -async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result<()> { +async fn installed_goal_tools_only_replace_complete_goal() -> anyhow::Result<()> { let runtime = test_runtime().await?; let thread_id = test_thread_id()?; seed_thread_metadata(runtime.as_ref(), thread_id).await?; @@ -152,10 +152,31 @@ async fn installed_goal_tools_reject_duplicate_goal_creation() -> anyhow::Result assert_eq!( err, FunctionCallError::RespondToModel( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + "cannot create a new goal because this thread has an unfinished goal; complete the existing goal first" .to_string() ) ); + + let update_tool = tool_by_name(&tools, "update_goal"); + update_tool + .handle(tool_call( + "update_goal", + "call-complete-goal", + json!({ "status": "complete" }), + )) + .await?; + + let invocation = tool_call( + "create_goal", + "call-create-goal-3", + json!({ "objective": "replacement goal" }), + ); + let output = create_tool.handle(invocation.clone()).await?; + let result = output.code_mode_result(&invocation.payload); + + assert_eq!(json!("replacement goal"), result["goal"]["objective"]); + assert_eq!(json!("active"), result["goal"]["status"]); + assert_eq!(json!(0), result["goal"]["tokensUsed"]); Ok(()) } diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index aa0e90462..50e8cf068 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -141,7 +141,16 @@ INSERT INTO thread_goals ( created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?) -ON CONFLICT(thread_id) DO NOTHING +ON CONFLICT(thread_id) DO UPDATE SET + goal_id = excluded.goal_id, + objective = excluded.objective, + status = excluded.status, + token_budget = excluded.token_budget, + tokens_used = 0, + time_used_seconds = 0, + created_at_ms = excluded.created_at_ms, + updated_at_ms = excluded.updated_at_ms +WHERE thread_goals.status = 'complete' RETURNING thread_id, goal_id,