Suppress request_user_input during active default goals

This commit is contained in:
shijie-openai
2026-06-09 22:25:30 -07:00
parent ba0f8c7bd1
commit f3283c261e
8 changed files with 211 additions and 13 deletions
@@ -11,6 +11,7 @@ use crate::tools::handlers::request_user_input_spec::request_user_input_tool_des
use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_extension_api::RequestUserInputSuppression;
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_tools::ToolName;
@@ -57,6 +58,12 @@ impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
));
}
if let Some(suppression) = turn.extension_data.get::<RequestUserInputSuppression>() {
return Err(FunctionCallError::RespondToModel(
suppression.unavailable_message().to_string(),
));
}
let mode = session.collaboration_mode().await.mode;
if let Some(message) = request_user_input_unavailable_message(mode, &self.available_modes) {
return Err(FunctionCallError::RespondToModel(message));
@@ -3,7 +3,9 @@ use crate::session::tests::make_session_and_context;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_extension_api::RequestUserInputSuppression;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
@@ -11,6 +13,59 @@ use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::test]
async fn request_user_input_rejects_suppressed_turns() {
let (session, turn) = make_session_and_context().await;
turn.extension_data
.insert(RequestUserInputSuppression::ActiveDefaultModeGoal);
let result = RequestUserInputHandler {
available_modes: vec![ModeKind::Default],
}
.handle(ToolInvocation {
session: Arc::new(session),
turn: Arc::new(turn),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
call_id: "call-1".to_string(),
tool_name: codex_tools::ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME),
source: crate::tools::context::ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: json!({
"questions": [{
"header": "Hdr",
"question": "Pick one",
"id": "pick_one",
"options": [
{
"label": "A",
"description": "A"
},
{
"label": "B",
"description": "B"
}
]
}]
})
.to_string(),
},
})
.await;
let Err(err) = result else {
panic!("suppressed request_user_input should fail");
};
assert_eq!(
err,
FunctionCallError::RespondToModel(
RequestUserInputSuppression::ActiveDefaultModeGoal
.unavailable_message()
.to_string(),
)
);
}
#[tokio::test]
async fn multi_agent_v2_request_user_input_rejects_subagent_threads() {
let (session, mut turn) = make_session_and_context().await;
+11 -1
View File
@@ -53,6 +53,7 @@ use crate::tools::registry::ToolRegistry;
use crate::tools::registry::override_tool_exposure;
use crate::tools::router::ToolRouter;
use crate::tools::router::ToolRouterParams;
use codex_extension_api::RequestUserInputSuppression;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_mcp::ToolInfo;
@@ -645,7 +646,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
planned_tools.add(PlanHandler);
if turn_context.config.experimental_request_user_input_enabled {
if turn_context.config.experimental_request_user_input_enabled
&& !request_user_input_suppressed_for_turn(turn_context)
{
planned_tools.add(RequestUserInputHandler {
available_modes: request_user_input_available_modes(features),
});
@@ -693,6 +696,13 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
}
}
fn request_user_input_suppressed_for_turn(turn_context: &TurnContext) -> bool {
turn_context
.extension_data
.get::<RequestUserInputSuppression>()
.is_some()
}
fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
let turn_context = context.turn_context;
if collab_tools_enabled(turn_context) {
@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use codex_extension_api::RequestUserInputSuppression;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -447,6 +448,17 @@ async fn request_user_input_tool_respects_experimental_config_gate() {
disabled.assert_registered_lacks(&["request_user_input"]);
}
#[tokio::test]
async fn request_user_input_tool_respects_turn_suppression() {
let suppressed = probe(|turn| {
turn.extension_data
.insert(RequestUserInputSuppression::ActiveDefaultModeGoal);
})
.await;
suppressed.assert_visible_lacks(&["request_user_input"]);
suppressed.assert_registered_lacks(&["request_user_input"]);
}
#[tokio::test]
async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() {
let plan = probe(|turn| {
+2
View File
@@ -2,6 +2,7 @@ mod capabilities;
mod contributors;
mod registry;
mod state;
mod tool_policy;
pub use capabilities::AgentSpawnFuture;
pub use capabilities::AgentSpawner;
@@ -62,3 +63,4 @@ pub use registry::ExtensionRegistryBuilder;
pub use registry::empty_extension_registry;
pub use state::ExtensionData;
pub use state::ExtensionDataInit;
pub use tool_policy::RequestUserInputSuppression;
@@ -0,0 +1,21 @@
/// Per-turn policy that suppresses the request_user_input tool.
///
/// Extensions insert this marker into turn-scoped [`ExtensionData`](crate::ExtensionData)
/// when they own context that makes blocking user input inappropriate for the
/// current turn.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RequestUserInputSuppression {
/// Suppress request_user_input while Default mode is working on an active goal.
ActiveDefaultModeGoal,
}
impl RequestUserInputSuppression {
/// Returns the model-facing message to use if the suppressed tool is invoked.
pub fn unavailable_message(self) -> &'static str {
match self {
Self::ActiveDefaultModeGoal => {
"request_user_input is unavailable while the current Default mode turn is working on an active goal"
}
}
}
}
+16 -9
View File
@@ -7,6 +7,7 @@ use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::RequestUserInputSuppression;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadResumeInput;
@@ -25,6 +26,7 @@ use codex_extension_api::TurnStartInput;
use codex_extension_api::TurnStopInput;
use codex_otel::MetricsClient;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -200,10 +202,7 @@ where
input.collaboration_mode.mode,
input.token_usage_at_turn_start,
);
if matches!(
input.collaboration_mode.mode,
codex_protocol::config_types::ModeKind::Plan
) {
if matches!(input.collaboration_mode.mode, ModeKind::Plan) {
accounting.clear_current_turn_goal();
return;
}
@@ -215,14 +214,22 @@ where
else {
return;
};
if let Some(goal) = goal
&& matches!(
if let Some(goal) = goal {
if goal.status == codex_state::ThreadGoalStatus::Active
&& input.collaboration_mode.mode == ModeKind::Default
{
input
.turn_store
.insert(RequestUserInputSuppression::ActiveDefaultModeGoal);
}
if matches!(
goal.status,
codex_state::ThreadGoalStatus::Active
| codex_state::ThreadGoalStatus::BudgetLimited
)
{
accounting.mark_turn_goal_active(input.turn_id, goal.goal_id);
) {
accounting.mark_turn_goal_active(input.turn_id, goal.goal_id);
}
}
}
@@ -9,6 +9,7 @@ use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::FunctionCallError;
use codex_extension_api::NoopTurnItemEmitter;
use codex_extension_api::RequestUserInputSuppression;
use codex_extension_api::ThreadResumeInput;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ThreadStopInput;
@@ -715,6 +716,83 @@ async fn usage_limit_plan_turn_does_not_stop_goal() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test]
async fn request_user_input_is_suppressed_for_active_default_goal_turn() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
runtime
.thread_goals()
.replace_thread_goal(
thread_id,
"ship goal extension backend",
codex_state::ThreadGoalStatus::Active,
/*token_budget*/ None,
)
.await?;
let harness = GoalExtensionHarness::new(runtime, thread_id).await?;
let default_turn_store = harness
.start_turn_with_mode("turn-default", ModeKind::Default, &TokenUsage::default())
.await;
assert_eq!(
Some(RequestUserInputSuppression::ActiveDefaultModeGoal),
default_turn_store
.get::<RequestUserInputSuppression>()
.map(|suppression| *suppression)
);
let plan_turn_store = harness
.start_turn_with_mode("turn-plan", ModeKind::Plan, &TokenUsage::default())
.await;
assert_eq!(
None,
plan_turn_store
.get::<RequestUserInputSuppression>()
.map(|suppression| *suppression)
);
Ok(())
}
#[tokio::test]
async fn request_user_input_is_not_suppressed_for_inactive_default_goal_turns() -> anyhow::Result<()>
{
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
for status in [
codex_state::ThreadGoalStatus::Paused,
codex_state::ThreadGoalStatus::Blocked,
codex_state::ThreadGoalStatus::UsageLimited,
codex_state::ThreadGoalStatus::BudgetLimited,
codex_state::ThreadGoalStatus::Complete,
] {
runtime
.thread_goals()
.replace_thread_goal(
thread_id,
"ship goal extension backend",
status,
/*token_budget*/ None,
)
.await?;
let turn_id = format!("turn-{status:?}");
let turn_store = harness
.start_turn_with_mode(&turn_id, ModeKind::Default, &TokenUsage::default())
.await;
assert_eq!(
None,
turn_store
.get::<RequestUserInputSuppression>()
.map(|suppression| *suppression),
"status {status:?} should not suppress request_user_input"
);
}
Ok(())
}
#[tokio::test]
async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<()> {
let runtime = test_runtime().await?;
@@ -1201,12 +1279,17 @@ impl GoalExtensionHarness {
.collect()
}
async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) {
async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) -> ExtensionData {
self.start_turn_with_mode(turn_id, ModeKind::Default, usage)
.await;
.await
}
async fn start_turn_with_mode(&self, turn_id: &str, mode: ModeKind, usage: &TokenUsage) {
async fn start_turn_with_mode(
&self,
turn_id: &str,
mode: ModeKind,
usage: &TokenUsage,
) -> ExtensionData {
let turn_store = ExtensionData::new(turn_id);
let mut collaboration_mode = default_collaboration_mode();
collaboration_mode.mode = mode;
@@ -1222,6 +1305,7 @@ impl GoalExtensionHarness {
})
.await;
}
turn_store
}
async fn stop_turn(&self, turn_id: &str) {