mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
Suppress request_user_input during active default goals
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user