diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9c2415678f..6678d9c0d1 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3124,6 +3124,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6e8b5d8a04..5d4af3d936 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3257,6 +3257,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 94abf054db..c4bb4a2ff4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15561,6 +15561,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 601e9dd7fb..c176c2a4b8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13385,6 +13385,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json index 52a2e905a2..cbb04eaab2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json @@ -51,6 +51,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts index 7a4bf332fb..46ec7ddd69 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadGoalStatus = "active" | "paused" | "budgetLimited" | "complete"; +export type ThreadGoalStatus = "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index ffe2c353d1..53f942cf80 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -555,6 +555,8 @@ v2_enum_from_core! { pub enum ThreadGoalStatus from CoreThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d0f423d5c7..5fa61b3c17 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -496,7 +496,7 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back ### Example: Set and update a thread goal -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. +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, `blocked` when progress is waiting on outside intervention, and `usageLimited` when usage availability stops further work. The system also sets `budgetLimited` when accounting crosses a configured token budget and `usageLimited` when a turn ends on a hard usage-limit error. ```json { "method": "thread/goal/set", "id": 27, "params": { @@ -529,12 +529,12 @@ Use `thread/goal/set` to create or update the current goal for a materialized th ```json { "method": "thread/goal/set", "id": 28, "params": { "threadId": "thr_123", - "status": "paused" + "status": "blocked" } } { "id": 28, "result": { "goal": { "threadId": "thr_123", "objective": "Keep improving the benchmark until p95 latency is under 120ms", - "status": "paused", + "status": "blocked", "tokenBudget": 200000, "tokensUsed": 10000, "timeUsedSeconds": 60, 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 a7c9a2c20c..0133114b84 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 @@ -454,6 +454,8 @@ fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadG match status { ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, } @@ -463,6 +465,8 @@ fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> Threa match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, } 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 852b86e562..c9a5ae1485 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -893,6 +893,92 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> Ok(()) } +#[tokio::test] +async fn thread_goal_set_persists_resumable_stopped_statuses() -> 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 mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + for (wire_status, expected_status) in [ + ("blocked", ThreadGoalStatus::Blocked), + ("usageLimited", ThreadGoalStatus::UsageLimited), + ] { + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id.clone(), + "objective": "keep polishing", + "status": wire_status, + })), + ) + .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)?; + assert_eq!(goal.goal.status, expected_status); + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadGoalUpdated(notification) = notification else { + anyhow::bail!("expected thread goal update notification"); + }; + assert_eq!(notification.goal.status, expected_status); + } + + Ok(()) +} + #[tokio::test] async fn thread_goal_set_edits_objective_without_resetting_usage() -> 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 717afb65c2..0f3b8e36eb 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -15,12 +15,14 @@ use crate::tasks::RegularTask; use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; use anyhow::Context; use codex_features::Feature; +use codex_otel::GOAL_BLOCKED_METRIC; use codex_otel::GOAL_BUDGET_LIMITED_METRIC; use codex_otel::GOAL_COMPLETED_METRIC; use codex_otel::GOAL_CREATED_METRIC; use codex_otel::GOAL_DURATION_SECONDS_METRIC; use codex_otel::GOAL_RESUMED_METRIC; use codex_otel::GOAL_TOKEN_COUNT_METRIC; +use codex_otel::GOAL_USAGE_LIMITED_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -156,6 +158,9 @@ pub(crate) enum GoalRuntimeEvent<'a> { turn_context: Option<&'a TurnContext>, reason: TurnAbortReason, }, + UsageLimitReached { + turn_context: &'a TurnContext, + }, ExternalMutationStarting, ExternalSet { external_set: ExternalGoalSet, @@ -393,6 +398,11 @@ impl Session { .await; Ok(()) }), + GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { + self.usage_limit_active_thread_goal_for_turn(turn_context) + .await?; + Ok(()) + }), GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move { if let Err(err) = self.account_thread_goal_before_external_mutation().await { tracing::warn!( @@ -537,6 +547,7 @@ impl Session { if replacing_goal { self.emit_goal_created_metric(); } + self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status); self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal); let goal = protocol_goal_from_state(goal); *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; @@ -653,6 +664,7 @@ impl Session { let previous_status = previous_goal .as_ref() .and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status)); + self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status); self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone())); let goal_id = goal.goal_id; @@ -681,7 +693,10 @@ impl Session { self.clear_stopped_thread_goal_runtime_state().await; } } - codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::Complete => { + codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited + | codex_state::ThreadGoalStatus::Complete => { self.clear_stopped_thread_goal_runtime_state().await; } } @@ -741,6 +756,25 @@ impl Session { .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); } + fn emit_goal_resumed_metric_if_status_changed( + &self, + previous_status: Option, + goal_status: codex_state::ThreadGoalStatus, + ) { + if goal_status == codex_state::ThreadGoalStatus::Active + && matches!( + previous_status, + Some( + codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited + ) + ) + { + self.emit_goal_resumed_metric(); + } + } + fn emit_goal_terminal_metrics_if_status_changed( &self, previous_status: Option, @@ -751,6 +785,8 @@ impl Session { } let counter = match goal.status { + codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC, + codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC, codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC, codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC, codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => { @@ -1011,6 +1047,8 @@ impl Session { matches!(budget_limit_steering, BudgetLimitSteering::Suppressed) } codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited | codex_state::ThreadGoalStatus::Complete => true, }; { @@ -1200,6 +1238,59 @@ impl Session { Ok(()) } + async fn usage_limit_active_thread_goal_for_turn( + &self, + turn_context: &TurnContext, + ) -> anyhow::Result<()> { + if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { + return Ok(()); + } + + if !self.enabled(Feature::Goals) { + return Ok(()); + } + + let _continuation_guard = self + .goal_runtime + .continuation_lock + .acquire() + .await + .context("goal continuation semaphore closed")?; + let Some(state_db) = self.state_db_for_thread_goals().await? else { + return Ok(()); + }; + self.account_thread_goal_progress( + turn_context, + BudgetLimitSteering::Suppressed, + TerminalMetricEmission::Emit, + ) + .await?; + let previous_status = self + .current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None) + .await?; + let Some(goal) = state_db + .thread_goals() + .usage_limit_active_thread_goal(self.conversation_id) + .await? + else { + return Ok(()); + }; + self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); + let goal = protocol_goal_from_state(goal); + *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; + self.clear_active_goal_accounting(turn_context).await; + self.send_event( + turn_context, + EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.conversation_id, + turn_id: Some(turn_context.sub_id.clone()), + goal, + }), + ) + .await; + Ok(()) + } + async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> { if !self.enabled(Feature::Goals) { return Ok(()); @@ -1239,6 +1330,8 @@ impl Session { self.emit_goal_resumed_metric(); } codex_state::ThreadGoalStatus::Paused + | codex_state::ThreadGoalStatus::Blocked + | codex_state::ThreadGoalStatus::UsageLimited | codex_state::ThreadGoalStatus::BudgetLimited | codex_state::ThreadGoalStatus::Complete => { self.clear_stopped_thread_goal_runtime_state().await; @@ -1594,6 +1687,8 @@ pub(crate) fn protocol_goal_status_from_state( match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, } @@ -1605,6 +1700,8 @@ pub(crate) fn state_goal_status_from_protocol( match status { ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, } @@ -1683,7 +1780,7 @@ mod tests { } #[test] - fn continuation_prompt_only_tells_model_to_update_goal_when_complete() { + fn continuation_prompt_allows_complete_and_strict_blocked_updates() { let prompt = continuation_prompt(&ThreadGoal { thread_id: ThreadId::new(), objective: "finish the stack".to_string(), @@ -1700,9 +1797,11 @@ mod tests { assert!(prompt.contains("\nfinish the stack\n")); assert!(prompt.contains("Token budget: 10000")); assert!(prompt.contains("call update_goal with status \"complete\"")); - assert!(!prompt.contains( - "explain the blocker or next required input to the user and wait for new input" - )); + assert!(prompt.contains("status \"blocked\"")); + assert!(prompt.contains("at least three consecutive goal turns")); + assert!(prompt.contains("same blocking condition")); + assert!(prompt.contains("original/user-triggered turn")); + assert!(prompt.contains("truly at an impasse")); assert!(!prompt.contains("budgetLimited")); assert!(!prompt.contains("status \"paused\"")); } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4511df4583..d3f6917775 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8629,6 +8629,57 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()> +{ + let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + sess.set_thread_goal( + tc.as_ref(), + SetGoalRequest { + objective: Some("Keep improving the benchmark".to_string()), + status: None, + token_budget: Some(Some(50)), + }, + ) + .await?; + sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { + turn_context: tc.as_ref(), + token_usage: TokenUsage::default(), + }) + .await?; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + set_total_token_usage(&sess, post_goal_token_usage()).await; + + sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: tc.as_ref(), + }) + .await?; + + let state_db = goal_test_state_db(sess.as_ref()).await?; + let goal = state_db + .thread_goals() + .get_thread_goal(sess.conversation_id) + .await? + .expect("goal should remain persisted after usage limiting"); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!(70, goal.tokens_used); + + sess.abort_all_tasks(TurnAbortReason::Replaced).await; + sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) + .await?; + assert!(sess.active_turn.lock().await.is_none()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; @@ -9550,7 +9601,121 @@ async fn update_goal_tool_rejects_pausing_goal() { }; assert_eq!( output, - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" + ); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Active); +} + +#[tokio::test] +async fn update_goal_tool_marks_goal_blocked() { + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; + + create_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + "token_budget": 123, + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + update_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "block-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "blocked", + }) + .to_string(), + }, + }) + .await + .expect("update_goal should mark the goal blocked"); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Blocked); +} + +#[tokio::test] +async fn update_goal_tool_rejects_usage_limited_goal() { + let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; + + create_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + let response = update_handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "usage-limit-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "usageLimited", + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = response else { + panic!("expected update_goal to reject usage-limiting a goal"); + }; + assert_eq!( + output, + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" ); let goal = session diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 96569d0bfa..ab316440e5 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -20,6 +20,7 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; +use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::emit_hook_completed_events; use crate::hook_runtime::inspect_pending_input; @@ -162,7 +163,16 @@ pub(crate) async fn run_turn( let pre_sampling_compact = match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await { Ok(pre_sampling_compact) => pre_sampling_compact, - Err(_) => { + Err(err) => { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } error!("Failed to run pre-sampling compact"); return None; } @@ -517,7 +527,20 @@ pub(crate) async fn run_turn( .await { Ok(reset_client_session) => reset_client_session, - Err(_) => return None, + Err(err) => { + if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!( + "failed to usage-limit active goal after usage-limit error: {err}" + ); + } + return None; + } }; if reset_client_session { client_session.reset_websocket_session(); @@ -673,6 +696,15 @@ pub(crate) async fn run_turn( } Err(e) => { info!("Turn error: {e:#}"); + if e.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded + && let Err(err) = sess + .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { + turn_context: turn_context.as_ref(), + }) + .await + { + warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; // let the user continue the conversation diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs index e50095e524..694eafca65 100644 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -1,8 +1,8 @@ //! Built-in model tool handlers for persisted thread goals. //! -//! The public tool contract intentionally splits goal creation from completion: -//! `create_goal` starts an active objective, while `update_goal` can only mark -//! the existing goal complete. +//! The public tool contract intentionally splits goal creation from stopped +//! status updates: `create_goal` starts an active objective, while +//! `update_goal` can only mark the existing goal complete or blocked. use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs index 8cf8ce546a..16883e0755 100644 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -51,9 +51,12 @@ impl ToolExecutor for UpdateGoalHandler { }; let args: UpdateGoalArgs = parse_arguments(&arguments)?; - if args.status != ThreadGoalStatus::Complete { + if !matches!( + args.status, + ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked + ) { return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" .to_string(), )); } @@ -68,13 +71,18 @@ impl ToolExecutor for UpdateGoalHandler { turn.as_ref(), SetGoalRequest { objective: None, - status: Some(ThreadGoalStatus::Complete), + status: Some(args.status), token_budget: None, }, ) .await .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(Some(goal), CompletionBudgetReport::Include).map(boxed_tool_output) + let completion_budget_report = if args.status == ThreadGoalStatus::Complete { + CompletionBudgetReport::Include + } else { + CompletionBudgetReport::Omit + }; + goal_response(Some(goal), completion_budget_report).map(boxed_tool_output) } } diff --git a/codex-rs/core/src/tools/handlers/goal_spec.rs b/codex-rs/core/src/tools/handlers/goal_spec.rs index a5ea0ad2f4..a3ae8703ce 100644 --- a/codex-rs/core/src/tools/handlers/goal_spec.rs +++ b/codex-rs/core/src/tools/handlers/goal_spec.rs @@ -63,9 +63,9 @@ pub fn create_update_goal_tool() -> ToolSpec { let properties = BTreeMap::from([( "status".to_string(), JsonSchema::string_enum( - vec![json!("complete")], + vec![json!("complete"), json!("blocked")], Some( - "Required. Set to complete only when the objective is achieved and no required work remains." + "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." .to_string(), ), ), @@ -74,10 +74,14 @@ pub fn create_update_goal_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: UPDATE_GOAL_TOOL_NAME.to_string(), description: r#"Update the existing goal. -Use this tool only to mark the goal achieved. +Use this tool only to mark the goal achieved or genuinely blocked. Set status to `complete` only when the objective has actually been achieved and no required work remains. +Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. +If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. +Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. +Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# .to_string(), strict: false, @@ -96,7 +100,7 @@ mod tests { use super::*; #[test] - fn update_goal_tool_only_exposes_complete_status() { + fn update_goal_tool_exposes_complete_and_blocked_statuses() { let ToolSpec::Function(tool) = create_update_goal_tool() else { panic!("update_goal should be a function tool"); }; @@ -107,6 +111,9 @@ mod tests { .and_then(|properties| properties.get("status")) .expect("status property should exist"); - assert_eq!(status.enum_values, Some(vec![json!("complete")])); + assert_eq!( + status.enum_values, + Some(vec![json!("complete"), json!("blocked")]) + ); } } diff --git a/codex-rs/core/templates/goals/continuation.md b/codex-rs/core/templates/goals/continuation.md index 5b4bd774ba..904d15c266 100644 --- a/codex-rs/core/templates/goals/continuation.md +++ b/codex-rs/core/templates/goals/continuation.md @@ -40,4 +40,12 @@ Before deciding that the goal is achieved, treat completion as unproven and veri Do not rely on intent, partial progress, memory of earlier work, or a plausible final answer as proof of completion. Marking the goal complete is a claim that the full objective has been finished and can withstand requirement-by-requirement scrutiny. Only mark the goal achieved when current evidence proves every requirement has been satisfied and no required work remains. If the evidence is incomplete, weak, indirect, merely consistent with completion, or leaves any requirement missing, incomplete, or unverified, keep working instead of marking the goal complete. If the objective is achieved, call update_goal with status "complete" so usage accounting is preserved. If the achieved goal has a token budget, report the final consumed token budget to the user after update_goal succeeds. -Do not call update_goal unless the goal is complete. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. +Blocked audit: +- Do not call update_goal with status "blocked" the first time a blocker appears. +- Only use status "blocked" when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic goal continuations. +- If the user resumes a goal that was previously marked "blocked", treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, call update_goal with status "blocked" again. +- Use status "blocked" only when you are truly at an impasse and cannot make meaningful progress without user input or an external-state change. +- Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; call update_goal with status "blocked". +- Never use status "blocked" merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. + +Do not call update_goal unless the goal is complete or the strict blocked audit above is satisfied. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 2d7dfdf880..17a39816a7 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -32,6 +32,8 @@ pub const GOAL_CREATED_METRIC: &str = "codex.goal.created"; pub const GOAL_RESUMED_METRIC: &str = "codex.goal.resumed"; pub const GOAL_COMPLETED_METRIC: &str = "codex.goal.completed"; pub const GOAL_BUDGET_LIMITED_METRIC: &str = "codex.goal.budget_limited"; +pub const GOAL_USAGE_LIMITED_METRIC: &str = "codex.goal.usage_limited"; +pub const GOAL_BLOCKED_METRIC: &str = "codex.goal.blocked"; pub const GOAL_TOKEN_COUNT_METRIC: &str = "codex.goal.token_count"; pub const GOAL_DURATION_SECONDS_METRIC: &str = "codex.goal.duration_s"; pub const PROFILE_USAGE_METRIC: &str = "codex.profile.usage"; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 94753780cb..98635ee30a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3561,6 +3561,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { pub enum ThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } diff --git a/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql b/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql new file mode 100644 index 0000000000..4e89f7469b --- /dev/null +++ b/codex-rs/state/migrations/0033_thread_goal_stopped_statuses.sql @@ -0,0 +1,48 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE thread_goals_new ( + thread_id TEXT PRIMARY KEY NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + goal_id TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ( + 'active', + 'paused', + 'blocked', + 'usage_limited', + 'budget_limited', + 'complete' + )), + token_budget INTEGER, + tokens_used INTEGER NOT NULL DEFAULT 0, + time_used_seconds INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +INSERT INTO thread_goals_new ( + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +) +SELECT + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +FROM thread_goals; + +DROP TABLE thread_goals; +ALTER TABLE thread_goals_new RENAME TO thread_goals; + +PRAGMA foreign_keys=ON; diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs index e943c145f8..4806250069 100644 --- a/codex-rs/state/src/model/thread_goal.rs +++ b/codex-rs/state/src/model/thread_goal.rs @@ -12,6 +12,8 @@ use super::epoch_millis_to_datetime; pub enum ThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } @@ -21,6 +23,8 @@ impl ThreadGoalStatus { match self { Self::Active => "active", Self::Paused => "paused", + Self::Blocked => "blocked", + Self::UsageLimited => "usage_limited", Self::BudgetLimited => "budget_limited", Self::Complete => "complete", } @@ -42,6 +46,8 @@ impl TryFrom<&str> for ThreadGoalStatus { match value { "active" => Ok(Self::Active), "paused" => Ok(Self::Paused), + "blocked" => Ok(Self::Blocked), + "usage_limited" => Ok(Self::UsageLimited), "budget_limited" => Ok(Self::BudgetLimited), "complete" => Ok(Self::Complete), other => Err(anyhow!("unknown thread goal status `{other}`")), diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index b43e8f69d5..9de561ef2d 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -195,7 +195,7 @@ UPDATE thread_goals SET objective = COALESCE(?, objective), status = CASE - WHEN status = ? AND ? = ? THEN status + WHEN status = ? AND ? IN (?, ?) THEN status WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ? ELSE ? END, @@ -209,6 +209,7 @@ WHERE thread_id = ? .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(crate::ThreadGoalStatus::Blocked.as_str()) .bind(status.as_str()) .bind(token_budget) .bind(token_budget) @@ -229,7 +230,7 @@ UPDATE thread_goals SET objective = COALESCE(?, objective), status = CASE - WHEN status = ? AND ? = ? THEN status + WHEN status = ? AND ? IN (?, ?) THEN status WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ? ELSE ? END, @@ -242,6 +243,7 @@ WHERE thread_id = ? .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(crate::ThreadGoalStatus::Blocked.as_str()) .bind(status.as_str()) .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) .bind(status.as_str()) @@ -323,6 +325,23 @@ WHERE thread_id = ? pub async fn pause_active_thread_goal( &self, thread_id: ThreadId, + ) -> anyhow::Result> { + self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::Paused) + .await + } + + pub async fn usage_limit_active_thread_goal( + &self, + thread_id: ThreadId, + ) -> anyhow::Result> { + self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::UsageLimited) + .await + } + + async fn update_active_thread_goal_status( + &self, + thread_id: ThreadId, + status: crate::ThreadGoalStatus, ) -> anyhow::Result> { let now_ms = datetime_to_epoch_millis(Utc::now()); let result = sqlx::query( @@ -332,12 +351,19 @@ SET status = ?, updated_at_ms = ? WHERE thread_id = ? - AND status = 'active' + AND ( + status = 'active' + OR ( + ? = 'usage_limited' + AND status = 'budget_limited' + ) + ) "#, ) - .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(status.as_str()) .bind(now_ms) .bind(thread_id.to_string()) + .bind(status.as_str()) .execute(self.pool.as_ref()) .await?; @@ -386,7 +412,7 @@ WHERE thread_id = ? "status IN ('active', 'budget_limited', 'complete')" } ThreadGoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'budget_limited')" + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; let budget_limit_status_filter = match mode { @@ -394,7 +420,7 @@ WHERE thread_id = ? | ThreadGoalAccountingMode::ActiveOnly | ThreadGoalAccountingMode::ActiveOrComplete => "status = 'active'", ThreadGoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'budget_limited')" + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" } }; let goal_id_filter = if expected_goal_id.is_some() { @@ -1019,6 +1045,66 @@ mod tests { ); } + #[tokio::test] + async fn usage_limit_active_thread_goal_updates_active_or_budget_limited_goals() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + let goal = runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "optimize the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ None, + ) + .await + .expect("goal replacement should succeed"); + + let usage_limited = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("usage limiting should succeed") + .expect("active goal should become usage limited"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::UsageLimited, + updated_at: usage_limited.updated_at, + ..goal + }; + assert_eq!(expected, usage_limited); + + let second_update = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("repeated usage limiting should succeed"); + assert_eq!(None, second_update); + + let budget_limited = runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "keep the usage failure visible", + crate::ThreadGoalStatus::BudgetLimited, + /*token_budget*/ Some(1), + ) + .await + .expect("goal replacement should succeed"); + let usage_limited = runtime + .thread_goals() + .usage_limit_active_thread_goal(thread_id) + .await + .expect("usage limiting should succeed") + .expect("budget-limited goal should become usage limited"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::UsageLimited, + updated_at: usage_limited.updated_at, + ..budget_limited + }; + assert_eq!(expected, usage_limited); + } + #[tokio::test] async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() { let runtime = test_runtime().await; @@ -1318,6 +1404,58 @@ mod tests { assert_eq!(50, paused.tokens_used); } + #[tokio::test] + async fn blocking_budget_limited_goal_preserves_terminal_status() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .thread_goals() + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(40), + ) + .await + .expect("goal replacement should succeed"); + let outcome = runtime + .thread_goals() + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 1, + /*token_delta*/ 50, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(budget_limited) = outcome else { + panic!("budget crossing should update the goal"); + }; + + let blocked = runtime + .thread_goals() + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + objective: None, + status: Some(crate::ThreadGoalStatus::Blocked), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + + let expected = crate::ThreadGoal { + updated_at: blocked.updated_at, + ..budget_limited + }; + assert_eq!(expected, blocked); + } + #[tokio::test] async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() { let runtime = test_runtime().await; diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index e43183ffc7..89b5465a96 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -63,7 +63,10 @@ impl App { let Some(goal) = response.goal else { return; }; - if goal.status == ThreadGoalStatus::Paused { + if matches!( + goal.status, + ThreadGoalStatus::Paused | ThreadGoalStatus::Blocked | ThreadGoalStatus::UsageLimited + ) { self.chat_widget .show_resume_paused_goal_prompt(thread_id, goal.objective); } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 0b6aabf5a9..8035881e8e 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -98,6 +98,8 @@ pub(crate) enum CollaborationModeIndicator { pub(crate) enum GoalStatusIndicator { Active { usage: Option }, Paused, + Blocked, + UsageLimited, BudgetLimited { usage: Option }, Complete { usage: Option }, } @@ -547,6 +549,8 @@ pub(crate) fn goal_status_indicator_line( } } GoalStatusIndicator::Paused => "Goal paused (/goal resume)".to_string(), + GoalStatusIndicator::Blocked => "Goal blocked (/goal resume)".to_string(), + GoalStatusIndicator::UsageLimited => "Goal hit usage limits (/goal resume)".to_string(), GoalStatusIndicator::BudgetLimited { usage } => { if let Some(usage) = usage { format!("Goal unmet ({usage})") diff --git a/codex-rs/tui/src/chatwidget/goal_menu.rs b/codex-rs/tui/src/chatwidget/goal_menu.rs index 19966d325f..ed48b5840d 100644 --- a/codex-rs/tui/src/chatwidget/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/goal_menu.rs @@ -103,7 +103,9 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec> { } let command_hint = match goal.status { AppThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear", - AppThreadGoalStatus::Paused => "Commands: /goal edit, /goal resume, /goal clear", + AppThreadGoalStatus::Paused + | AppThreadGoalStatus::Blocked + | AppThreadGoalStatus::UsageLimited => "Commands: /goal edit, /goal resume, /goal clear", AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => { "Commands: /goal edit, /goal clear" } @@ -117,6 +119,8 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str { match status { AppThreadGoalStatus::Active => "active", AppThreadGoalStatus::Paused => "paused", + AppThreadGoalStatus::Blocked => "blocked", + AppThreadGoalStatus::UsageLimited => "usage limited", AppThreadGoalStatus::BudgetLimited => "limited by budget", AppThreadGoalStatus::Complete => "complete", } @@ -125,7 +129,9 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str { fn edited_goal_status(status: AppThreadGoalStatus) -> AppThreadGoalStatus { match status { AppThreadGoalStatus::Active => AppThreadGoalStatus::Active, - AppThreadGoalStatus::Paused => AppThreadGoalStatus::Paused, + AppThreadGoalStatus::Paused + | AppThreadGoalStatus::Blocked + | AppThreadGoalStatus::UsageLimited => status, AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => { AppThreadGoalStatus::Active } diff --git a/codex-rs/tui/src/chatwidget/goal_status.rs b/codex-rs/tui/src/chatwidget/goal_status.rs index e641159a3b..e64c5be376 100644 --- a/codex-rs/tui/src/chatwidget/goal_status.rs +++ b/codex-rs/tui/src/chatwidget/goal_status.rs @@ -50,6 +50,8 @@ pub(super) fn goal_status_indicator_from_app_goal( usage: active_goal_usage(goal.token_budget, goal.tokens_used, goal.time_used_seconds), }), AppThreadGoalStatus::Paused => Some(GoalStatusIndicator::Paused), + AppThreadGoalStatus::Blocked => Some(GoalStatusIndicator::Blocked), + AppThreadGoalStatus::UsageLimited => Some(GoalStatusIndicator::UsageLimited), AppThreadGoalStatus::BudgetLimited => Some(GoalStatusIndicator::BudgetLimited { usage: stopped_goal_budget_usage(goal.token_budget, goal.tokens_used), }), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 2a88711f48..0819bd6c03 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -375,6 +375,7 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.bottom_pane.clear_quit_shortcut_hint(); + self.pause_active_goal_for_interrupt(); self.submit_op(AppCommand::interrupt()); } else { self.request_quit_without_confirmation(); @@ -392,6 +393,7 @@ impl ChatWidget { self.arm_quit_shortcut(key); if self.is_cancellable_work_active() { + self.pause_active_goal_for_interrupt(); self.submit_op(AppCommand::interrupt()); } } @@ -452,4 +454,24 @@ impl ChatWidget { fn is_cancellable_work_active(&self) -> bool { self.bottom_pane.is_task_running() || self.review.is_review_mode } + + fn pause_active_goal_for_interrupt(&self) { + if !self.turn_lifecycle.agent_turn_running { + return; + } + if !self + .current_goal_status + .as_ref() + .is_some_and(GoalStatusState::is_active) + { + return; + } + let Some(thread_id) = self.thread_id else { + return; + }; + self.app_event_tx.send(AppEvent::SetThreadGoalStatus { + thread_id, + status: AppThreadGoalStatus::Paused, + }); + } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap new file mode 100644 index 0000000000..40de732645 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_blocked.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: blocked +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K + +Commands: /goal edit, /goal resume, /goal clear diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap new file mode 100644 index 0000000000..747ed25d27 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__goal_menu_usage_limited.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/goal_menu.rs +expression: rendered_goal_summary(&mut rx) +--- +Goal +Status: usage limited +Objective: Keep improving the bare goal command until it feels calm and useful. +Time used: 1m +Tokens used: 12.5K + +Commands: /goal edit, /goal resume, /goal clear diff --git a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs index e7f3077513..6ccc0220bf 100644 --- a/codex-rs/tui/src/chatwidget/tests/goal_menu.rs +++ b/codex-rs/tui/src/chatwidget/tests/goal_menu.rs @@ -28,6 +28,34 @@ async fn goal_menu_paused_snapshot() { assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx)); } +#[tokio::test] +async fn goal_menu_blocked_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::Blocked, + /*token_budget*/ None, + )); + + assert_chatwidget_snapshot!("goal_menu_blocked", rendered_goal_summary(&mut rx)); +} + +#[tokio::test] +async fn goal_menu_usage_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::UsageLimited, + /*token_budget*/ None, + )); + + assert_chatwidget_snapshot!("goal_menu_usage_limited", 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; @@ -117,6 +145,42 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() { assert!(chat.no_modal_or_popup_active()); } +#[tokio::test] +async fn goal_edit_prompt_preserves_resumable_stopped_statuses() { + for stopped_status in [ + AppThreadGoalStatus::Blocked, + AppThreadGoalStatus::UsageLimited, + ] { + 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, + stopped_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, stopped_status); + assert_eq!(token_budget, Some(80_000)); + } + other => panic!("expected SetThreadGoalObjective event, got {other:?}"), + } + } +} + #[tokio::test] async fn goal_edit_prompt_resets_terminal_status_to_active() { let cases = [ diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index acd6b7111b..ac3e236822 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1136,6 +1136,45 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); } +#[tokio::test] +async fn ctrl_c_interrupt_pauses_active_goal_turn() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + chat.thread_id = Some(thread_id); + let mut goal = test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Active, + /*token_budget*/ Some(50_000), + /*tokens_used*/ 40_000, + ); + goal.thread_id = thread_id.to_string(); + chat.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal, + }, + ), + /*replay_kind*/ None, + ); + chat.on_task_started(); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SetThreadGoalStatus { + thread_id: event_thread_id, + status: AppThreadGoalStatus::Paused, + }) if event_thread_id == thread_id + ); +} + #[tokio::test] async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -2293,6 +2332,22 @@ fn goal_status_indicator_formats_statuses_and_budgets() { usage: Some("30m".to_string()), }) ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::Blocked, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::Blocked) + ); + assert_eq!( + goal_status_indicator_from_app_goal(&test_thread_goal( + codex_app_server_protocol::ThreadGoalStatus::UsageLimited, + /*token_budget*/ None, + /*tokens_used*/ 0, + )), + Some(GoalStatusIndicator::UsageLimited) + ); assert_eq!( goal_status_indicator_from_app_goal(&test_thread_goal( codex_app_server_protocol::ThreadGoalStatus::BudgetLimited, @@ -2339,6 +2394,11 @@ fn goal_status_indicator_line_formats_goal_text() { "Goal unmet (4K / 5K tokens)", ), (GoalStatusIndicator::Paused, "Goal paused (/goal resume)"), + (GoalStatusIndicator::Blocked, "Goal blocked (/goal resume)"), + ( + GoalStatusIndicator::UsageLimited, + "Goal hit usage limits (/goal resume)", + ), ( GoalStatusIndicator::BudgetLimited { usage: None }, "Goal abandoned", diff --git a/codex-rs/tui/src/goal_display.rs b/codex-rs/tui/src/goal_display.rs index d0455054b7..961b87f16e 100644 --- a/codex-rs/tui/src/goal_display.rs +++ b/codex-rs/tui/src/goal_display.rs @@ -32,6 +32,8 @@ pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str { match status { ThreadGoalStatus::Active => "active", ThreadGoalStatus::Paused => "paused", + ThreadGoalStatus::Blocked => "blocked", + ThreadGoalStatus::UsageLimited => "usage limited", ThreadGoalStatus::BudgetLimited => "limited by budget", ThreadGoalStatus::Complete => "complete", }