mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
1. Adds SkillScope::Public end-to-end (core + protocol) and loads skills from the public cache directory 2. Improves repo skill discovery by searching upward for the nearest .codex/skills within a git repo 3. Deduplicates skills by name with deterministic ordering to avoid duplicates across sources 4. Fixes garbled “Skill errors” overlay rendering by preventing pending history lines from being injected during the modal 5. Updates the project docs “Skills” intro wording to avoid hardcoded paths
219 lines
7.1 KiB
Rust
219 lines
7.1 KiB
Rust
#![cfg(not(target_os = "windows"))]
|
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
|
|
use anyhow::Result;
|
|
use codex_core::features::Feature;
|
|
use codex_core::protocol::AskForApproval;
|
|
use codex_core::protocol::Op;
|
|
use codex_core::protocol::SandboxPolicy;
|
|
use codex_protocol::user_input::UserInput;
|
|
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::test_codex::test_codex;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> std::path::PathBuf {
|
|
let skill_dir = home.join("skills").join(name);
|
|
fs::create_dir_all(&skill_dir).unwrap();
|
|
let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n");
|
|
let path = skill_dir.join("SKILL.md");
|
|
fs::write(&path, contents).unwrap();
|
|
path
|
|
}
|
|
|
|
fn write_public_skill(
|
|
home: &Path,
|
|
name: &str,
|
|
description: &str,
|
|
body: &str,
|
|
) -> std::path::PathBuf {
|
|
let skill_dir = home.join("skills").join(".public").join(name);
|
|
fs::create_dir_all(&skill_dir).unwrap();
|
|
let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n");
|
|
let path = skill_dir.join("SKILL.md");
|
|
fs::write(&path, contents).unwrap();
|
|
path
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn user_turn_includes_skill_instructions() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let skill_body = "skill body";
|
|
let mut builder = test_codex()
|
|
.with_config(|cfg| {
|
|
cfg.features.enable(Feature::Skills);
|
|
})
|
|
.with_pre_build_hook(|home| {
|
|
write_skill(home, "demo", "demo skill", skill_body);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
let skill_path = test.codex_home_path().join("skills/demo/SKILL.md");
|
|
let skill_path = std::fs::canonicalize(skill_path)?;
|
|
|
|
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();
|
|
test.codex
|
|
.submit(Op::UserTurn {
|
|
items: vec![
|
|
UserInput::Text {
|
|
text: "please use $demo".to_string(),
|
|
},
|
|
UserInput::Skill {
|
|
name: "demo".to_string(),
|
|
path: skill_path.clone(),
|
|
},
|
|
],
|
|
final_output_json_schema: None,
|
|
cwd: test.cwd_path().to_path_buf(),
|
|
approval_policy: AskForApproval::Never,
|
|
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
|
model: session_model,
|
|
effort: None,
|
|
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
|
})
|
|
.await?;
|
|
|
|
core_test_support::wait_for_event(test.codex.as_ref(), |event| {
|
|
matches!(event, codex_core::protocol::EventMsg::TaskComplete(_))
|
|
})
|
|
.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 skill_load_errors_surface_in_session_configured() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex()
|
|
.with_config(|cfg| {
|
|
cfg.features.enable(Feature::Skills);
|
|
})
|
|
.with_pre_build_hook(|home| {
|
|
let skill_dir = home.join("skills").join("broken");
|
|
fs::create_dir_all(&skill_dir).unwrap();
|
|
fs::write(skill_dir.join("SKILL.md"), "not yaml").unwrap();
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.submit(Op::ListSkills {
|
|
cwds: Vec::new(),
|
|
force_reload: false,
|
|
})
|
|
.await?;
|
|
let response =
|
|
core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event {
|
|
codex_core::protocol::EventMsg::ListSkillsResponse(response) => Some(response.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
let cwd = test.cwd_path();
|
|
let (skills, errors) = response
|
|
.skills
|
|
.iter()
|
|
.find(|entry| entry.cwd.as_path() == cwd)
|
|
.map(|entry| (entry.skills.clone(), entry.errors.clone()))
|
|
.unwrap_or_default();
|
|
|
|
assert!(
|
|
skills.iter().all(|skill| {
|
|
!skill
|
|
.path
|
|
.to_string_lossy()
|
|
.ends_with("skills/broken/SKILL.md")
|
|
}),
|
|
"expected broken skill not loaded, got {skills:?}"
|
|
);
|
|
assert_eq!(errors.len(), 1, "expected one load error");
|
|
let error_path = errors[0].path.to_string_lossy();
|
|
assert!(
|
|
error_path.ends_with("skills/broken/SKILL.md"),
|
|
"unexpected error path: {error_path}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn list_skills_includes_public_cache_entries() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex()
|
|
.with_config(|cfg| {
|
|
cfg.features.enable(Feature::Skills);
|
|
})
|
|
.with_pre_build_hook(|home| {
|
|
write_public_skill(home, "public-demo", "public skill", "public body");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.codex
|
|
.submit(Op::ListSkills {
|
|
cwds: Vec::new(),
|
|
force_reload: true,
|
|
})
|
|
.await?;
|
|
let response =
|
|
core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event {
|
|
codex_core::protocol::EventMsg::ListSkillsResponse(response) => Some(response.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
let cwd = test.cwd_path();
|
|
let (skills, _errors) = response
|
|
.skills
|
|
.iter()
|
|
.find(|entry| entry.cwd.as_path() == cwd)
|
|
.map(|entry| (entry.skills.clone(), entry.errors.clone()))
|
|
.unwrap_or_default();
|
|
|
|
let skill = skills
|
|
.iter()
|
|
.find(|skill| skill.name == "public-demo")
|
|
.expect("expected public skill to be present");
|
|
assert_eq!(skill.scope, codex_protocol::protocol::SkillScope::Public);
|
|
let path_str = skill.path.to_string_lossy().replace('\\', "/");
|
|
assert!(
|
|
path_str.ends_with("/skills/.public/public-demo/SKILL.md"),
|
|
"unexpected skill path: {path_str}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|