Compare commits

...

2 Commits

Author SHA1 Message Date
Felipe Coury
a906903e13 fix(core): restrict modal questions to supported hosts 2026-05-26 14:36:32 -03:00
Felipe Coury
816e48ccca feat(core): enable default-mode user questions 2026-05-26 13:41:31 -03:00
14 changed files with 118 additions and 95 deletions

View File

@@ -1318,8 +1318,7 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> {
}
#[tokio::test]
async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_description_v2()
-> Result<()> {
async fn turn_start_includes_default_mode_request_user_input_tool_description_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -1344,10 +1343,6 @@ async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_de
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.3-codex".to_string()),
config: Some(HashMap::from([(
"features.default_mode_request_user_input".to_string(),
json!(true),
)])),
..Default::default()
})
.await?;

View File

@@ -8,4 +8,4 @@ Your active mode changes only when new developer instructions with a different `
Use the `request_user_input` tool only when it is listed in the available tools for this turn.
In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, use `request_user_input` when it is available. If the tool is unavailable, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.

View File

@@ -8524,10 +8524,6 @@ async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() ->
.features
.enable(Feature::Goals)
.expect("goal mode should be enableable in tests");
config
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("default-mode request_user_input should be enableable in tests");
});
let test = builder.build(&server).await?;
let responses = mount_sse_sequence(

View File

@@ -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 crate::tools::registry::ToolExposure;
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_tools::ToolName;
@@ -30,6 +31,10 @@ impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
create_request_user_input_tool(request_user_input_tool_description(&self.available_modes))
}
fn exposure(&self) -> ToolExposure {
ToolExposure::DirectModelOnly
}
async fn handle(
&self,
invocation: ToolInvocation,

View File

@@ -1,20 +1,12 @@
use super::*;
use codex_features::Feature;
use codex_features::Features;
use codex_protocol::config_types::ModeKind;
use codex_tools::JsonSchema;
use codex_tools::request_user_input_available_modes;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn default_mode_enabled_available_modes() -> Vec<ModeKind> {
let mut features = Features::with_defaults();
features.enable(Feature::DefaultModeRequestUserInput);
request_user_input_available_modes(&features)
}
fn default_available_modes() -> Vec<ModeKind> {
request_user_input_available_modes(&Features::with_defaults())
fn available_modes() -> Vec<ModeKind> {
request_user_input_available_modes()
}
#[test]
@@ -103,31 +95,21 @@ fn request_user_input_tool_includes_questions_schema() {
}
#[test]
fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
fn request_user_input_unavailable_messages_respect_supported_modes() {
assert_eq!(
request_user_input_unavailable_message(ModeKind::Plan, &default_available_modes()),
request_user_input_unavailable_message(ModeKind::Plan, &available_modes()),
None
);
assert_eq!(
request_user_input_unavailable_message(ModeKind::Default, &default_available_modes()),
Some("request_user_input is unavailable in Default mode".to_string())
);
assert_eq!(
request_user_input_unavailable_message(
ModeKind::Default,
&default_mode_enabled_available_modes()
),
request_user_input_unavailable_message(ModeKind::Default, &available_modes()),
None
);
assert_eq!(
request_user_input_unavailable_message(ModeKind::Execute, &default_available_modes()),
request_user_input_unavailable_message(ModeKind::Execute, &available_modes()),
Some("request_user_input is unavailable in Execute mode".to_string())
);
assert_eq!(
request_user_input_unavailable_message(
ModeKind::PairProgramming,
&default_available_modes()
),
request_user_input_unavailable_message(ModeKind::PairProgramming, &available_modes()),
Some("request_user_input is unavailable in Pair Programming mode".to_string())
);
}
@@ -135,11 +117,7 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() {
#[test]
fn request_user_input_tool_description_mentions_available_modes() {
assert_eq!(
request_user_input_tool_description(&default_available_modes()),
"Request user input for one to three short questions and wait for the response. This tool is only available in Plan mode.".to_string()
);
assert_eq!(
request_user_input_tool_description(&default_mode_enabled_available_modes()),
request_user_input_tool_description(&available_modes()),
"Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string()
);
}

View File

@@ -568,9 +568,13 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
planned_tools.add(UpdateGoalHandler);
}
planned_tools.add(RequestUserInputHandler {
available_modes: request_user_input_available_modes(features),
});
if !turn_context.session_source.is_non_root_agent()
&& !matches!(&turn_context.session_source, SessionSource::Exec)
{
planned_tools.add(RequestUserInputHandler {
available_modes: request_user_input_available_modes(),
});
}
if features.enabled(Feature::RequestPermissionsTool) {
planned_tools.add(RequestPermissionsHandler);

View File

@@ -442,6 +442,33 @@ async fn host_context_gates_goal_and_agent_job_tools() {
worker_agent_job.assert_visible_contains(&["spawn_agents_on_csv", "report_agent_job_result"]);
}
#[tokio::test]
async fn request_user_input_is_hidden_from_unsupported_session_sources() {
let interactive_root = probe(|turn| {
turn.session_source = SessionSource::VSCode;
})
.await;
interactive_root.assert_visible_contains(&["request_user_input"]);
let exec = probe(|turn| {
turn.session_source = SessionSource::Exec;
})
.await;
exec.assert_visible_lacks(&["request_user_input"]);
let sub_agent = probe(|turn| {
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: Default::default(),
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
});
})
.await;
sub_agent.assert_visible_lacks(&["request_user_input"]);
}
#[tokio::test]
async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
let direct_mcp = probe_with(
@@ -697,6 +724,22 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() {
);
}
#[tokio::test]
async fn code_mode_only_keeps_request_user_input_as_a_direct_modal_tool() {
let plan = probe(|turn| {
turn.session_source = SessionSource::VSCode;
set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);
})
.await;
// Modal prompts must not be routed through a yielded exec cell.
plan.assert_visible_contains(&["request_user_input"]);
assert_eq!(
plan.exposure("request_user_input"),
ToolExposure::DirectModelOnly
);
}
#[tokio::test]
async fn multi_agent_feature_selects_one_agent_tool_family() {
let v1 = probe(|turn| {
@@ -882,6 +925,7 @@ async fn multi_agent_v2_namespace_is_ignored_without_provider_namespace_support(
#[tokio::test]
async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
let plan = probe(|turn| {
turn.session_source = SessionSource::VSCode;
set_features(
turn,
&[
@@ -897,7 +941,10 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
})
.await;
assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]);
assert_eq!(
plan.visible_names,
vec!["exec", "wait", "request_user_input", "agents"]
);
for tool_name in [
"spawn_agent",
"send_message",

View File

@@ -2,7 +2,6 @@
use std::collections::HashMap;
use codex_features::Feature;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
@@ -82,24 +81,12 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
let server = start_mock_server().await;
let builder = test_codex();
#[allow(clippy::expect_used)]
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder
.with_config(move |config| {
if mode == ModeKind::Default {
config
.features
.enable(Feature::DefaultModeRequestUserInput)
.expect("test config should allow feature update");
}
})
.build(&server)
.await?;
} = test_codex().build(&server).await?;
let call_id = "user-input-call";
let request_args = json!({
@@ -429,20 +416,7 @@ async fn request_user_input_rejected_in_execute_mode_alias() -> anyhow::Result<(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_rejected_in_default_mode_by_default() -> anyhow::Result<()> {
assert_request_user_input_rejected("Default", |model| CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: None,
developer_instructions: None,
},
})
.await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn request_user_input_round_trip_in_default_mode_with_feature() -> anyhow::Result<()> {
async fn request_user_input_round_trip_in_default_mode() -> anyhow::Result<()> {
request_user_input_round_trip_for_mode(ModeKind::Default).await
}

View File

@@ -172,7 +172,7 @@ pub enum Feature {
SkillEnvVarDependencyPrompt,
/// Enable the unified mention popup prototype.
MentionsV2,
/// Allow request_user_input in Default collaboration mode.
/// Removed compatibility flag; request_user_input is always available in Default mode.
DefaultModeRequestUserInput,
/// Enable automatic review for approval prompts.
GuardianApproval,
@@ -440,6 +440,9 @@ impl Features {
"skill_env_var_dependency_prompt" => {
continue;
}
"default_mode_request_user_input" => {
continue;
}
"use_legacy_landlock" => {
self.record_legacy_usage_force(
"features.use_legacy_landlock",
@@ -1068,7 +1071,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::DefaultModeRequestUserInput,
key: "default_mode_request_user_input",
stage: Stage::UnderDevelopment,
stage: Stage::Removed,
default_enabled: false,
},
FeatureSpec {

View File

@@ -288,6 +288,19 @@ fn js_repl_features_are_removed_feature_keys() {
);
}
#[test]
fn default_mode_request_user_input_is_a_removed_feature_key() {
assert_eq!(Feature::DefaultModeRequestUserInput.stage(), Stage::Removed);
assert_eq!(
Feature::DefaultModeRequestUserInput.default_enabled(),
false
);
assert_eq!(
feature_for_key("default_mode_request_user_input"),
Some(Feature::DefaultModeRequestUserInput)
);
}
#[test]
fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable);
@@ -483,6 +496,25 @@ fn from_sources_ignores_removed_js_repl_feature_keys() {
assert_eq!(features, Features::with_defaults());
}
#[test]
fn from_sources_ignores_removed_default_mode_request_user_input_feature_key() {
let features_toml = FeaturesToml::from(BTreeMap::from([(
"default_mode_request_user_input".to_string(),
true,
)]));
let features = Features::from_sources(
FeatureConfigSource {
features: Some(&features_toml),
..Default::default()
},
FeatureConfigSource::default(),
FeatureOverrides::default(),
);
assert_eq!(features, Features::with_defaults());
}
#[test]
fn from_sources_ignores_removed_apply_patch_freeform_feature_key() {
let features_toml =

View File

@@ -30,7 +30,8 @@ fn default_mode_instructions_replace_mode_names_placeholder() {
assert!(default_instructions.contains(
"Use the `request_user_input` tool only when it is listed in the available tools"
));
assert!(
default_instructions.contains("ask the user directly with a concise plain-text question")
);
assert!(default_instructions.contains("use `request_user_input` when it is available"));
assert!(default_instructions.contains(
"If the tool is unavailable, ask the user directly with a concise plain-text question"
));
}

View File

@@ -612,7 +612,7 @@ impl ModeKind {
}
pub const fn allows_request_user_input(self) -> bool {
matches!(self, Self::Plan)
matches!(self, Self::Default | Self::Plan)
}
}

View File

@@ -22,14 +22,10 @@ pub enum ToolUserShellType {
Cmd,
}
pub fn request_user_input_available_modes(features: &Features) -> Vec<ModeKind> {
pub fn request_user_input_available_modes() -> Vec<ModeKind> {
TUI_VISIBLE_COLLABORATION_MODES
.into_iter()
.filter(|mode| {
mode.allows_request_user_input()
|| (features.enabled(Feature::DefaultModeRequestUserInput)
&& *mode == ModeKind::Default)
})
.filter(|mode| mode.allows_request_user_input())
.collect()
}

View File

@@ -110,17 +110,9 @@ fn shell_command_backend_requires_both_shell_tool_and_zsh_fork() {
}
#[test]
fn request_user_input_modes_follow_default_mode_feature() {
let mut features = Features::with_defaults();
features.disable(Feature::DefaultModeRequestUserInput);
fn request_user_input_modes_include_default_and_plan() {
assert_eq!(
request_user_input_available_modes(&features),
vec![ModeKind::Plan]
);
features.enable(Feature::DefaultModeRequestUserInput);
assert_eq!(
request_user_input_available_modes(&features),
request_user_input_available_modes(),
vec![ModeKind::Default, ModeKind::Plan]
);
}