Files
codex/codex-rs/core/tests/suite/skills.rs
T
efrazer-oai 989a0b053e Accept user input when starting idle turns (#36947)
## What changed

- Generalize `CodexThread::try_start_turn_if_idle` to accept exported
  `TurnInput` values, preserving user content and client IDs in the first model
  request.
- Allow non-empty user input to start an idle turn in Plan mode while retaining
  the Plan-mode rejection for automatic response items and empty user input.
- Process idle user input through the normal user-turn path so connector
  selections are cleared, prompt telemetry is recorded, and referenced skill
  instructions are included.

## Testing

- Cover idle user input in Default and Plan modes, response-item mailbox
  merging, empty-input rejection, and skill instruction injection.

GitOrigin-RevId: 116667d160d4464b525d9e4c7e8cc05d23a94008
2026-08-04 18:52:41 +00:00

202 lines
7.3 KiB
Rust

#![cfg(not(target_os = "windows"))]
#![allow(clippy::unwrap_used)]
use anyhow::Result;
use codex_core::TurnInput;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_target_windows;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use std::sync::Arc;
async fn write_repo_skill(
cwd: AbsolutePathBuf,
fs: Arc<dyn ExecutorFileSystem>,
name: &str,
description: &str,
body: &str,
) -> Result<()> {
let skill_dir = cwd.join(".agents").join("skills").join(name);
let skill_dir_uri = PathUri::from_host_native_path(&skill_dir)?;
fs.create_directory(
&skill_dir_uri,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n");
let path = skill_dir.join("SKILL.md");
let path_uri = PathUri::from_host_native_path(&path)?;
fs.write_file(&path_uri, contents.into_bytes(), /*sandbox*/ None)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_turn_includes_skill_instructions() -> Result<()> {
// TODO(anp): Remove after skill-path helpers use target-native paths.
skip_if_target_windows!(Ok(()), "requires native cross-OS skill paths");
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let skill_body = "skill body";
let mut builder = test_codex().with_workspace_setup(move |cwd, fs| async move {
write_repo_skill(cwd, fs, "demo", "demo skill", skill_body).await
});
let test = builder.build_with_auto_env(&server).await?;
let skill_path = test
.config
.cwd
.join(".agents/skills/demo/SKILL.md")
.canonicalize()
.unwrap_or_else(|_| test.config.cwd.join(".agents/skills/demo/SKILL.md"))
.to_path_buf();
let mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
let session_model = test.session_configured.model.clone();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
test.codex
.submit(Op::UserInput {
items: vec![
UserInput::Text {
text: "please use $demo".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "demo".to_string(),
path: skill_path.clone(),
},
],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
environments: Some(local_selections(test.config.cwd.clone())),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
},
})
.await?;
core_test_support::wait_for_event(test.codex.as_ref(), |event| {
matches!(event, codex_protocol::protocol::EventMsg::TurnComplete(_))
})
.await;
let request = mock.single_request();
let user_texts = request.message_input_texts("user");
let skill_path_str = skill_path.to_string_lossy();
assert!(
user_texts.iter().any(|text| {
text.contains("<skill>\n<name>demo</name>")
&& text.contains("<path>")
&& text.contains(skill_body)
&& text.contains(skill_path_str.as_ref())
}),
"expected skill instructions in user input, got {user_texts:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn idle_user_turn_includes_skill_instructions_in_the_first_request() -> Result<()> {
skip_if_target_windows!(Ok(()), "requires native cross-OS skill paths");
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let skill_body = "queued skill body";
let mut builder = test_codex().with_workspace_setup(move |cwd, fs| async move {
write_repo_skill(cwd, fs, "queued-demo", "queued demo skill", skill_body).await
});
let test = builder.build_with_auto_env(&server).await?;
let skill_path = test
.config
.cwd
.join(".agents/skills/queued-demo/SKILL.md")
.canonicalize()
.unwrap_or_else(|_| test.config.cwd.join(".agents/skills/queued-demo/SKILL.md"))
.to_path_buf();
let mock = mount_sse_once(
&server,
sse(vec![
ev_response_created("queued-skill-response"),
ev_assistant_message("queued-skill-message", "done"),
ev_completed("queued-skill-response"),
]),
)
.await;
test.codex
.try_start_turn_if_idle(vec![TurnInput::UserInput {
content: vec![
UserInput::Text {
text: "please use $queued-demo".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "queued-demo".to_string(),
path: skill_path.clone(),
},
],
client_id: Some("queued-skill-user-message".to_string()),
}])
.await
.map_err(|error| anyhow::anyhow!("idle skill input was rejected: {:?}", error.reason()))?;
core_test_support::wait_for_event(test.codex.as_ref(), |event| {
matches!(event, codex_protocol::protocol::EventMsg::TurnComplete(_))
})
.await;
let user_texts = mock.single_request().message_input_texts("user");
let skill_path_str = skill_path.to_string_lossy();
assert!(
user_texts.iter().any(|text| {
text.contains("<skill>\n<name>queued-demo</name>")
&& text.contains("<path>")
&& text.contains(skill_body)
&& text.contains(skill_path_str.as_ref())
}),
"expected queued skill instructions in the first request, got {user_texts:?}"
);
Ok(())
}