mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
## Why Skill tools depended on World State rendering to initialize their catalogs and could return `skill catalog is not ready for this step` before rendering completed. ## What changed - Add an asynchronous `ToolContributor::prepare_for_step` hook and await all contributors before constructing tools. - Prepare immutable skill catalogs, aliases, plugin policy, and configuration for each step. Tools, invocation detection, and World State rendering consume the prepared state without relying on rendering to perform discovery. - Apply configuration reloads to subsequent steps while preserving live authorization checks for skill reads. ## Testing Add coverage for preparation completing before tool construction and World State rendering, skill tools working before rendering, catalogs remaining stable across steps, and configuration reloads during discovery. GitOrigin-RevId: 1fb95c58c412488353f3bd4403824d6d1476d3d0
3759 lines
139 KiB
Rust
3759 lines
139 KiB
Rust
use codex_protocol_test_support::ContentItemTestExt;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use assert_matches::assert_matches;
|
|
use codex_attachment_store::InlineAttachmentStore;
|
|
use codex_config::ConfigLayerEntry;
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_config::ConfigRequirementsToml;
|
|
use codex_exec_server::CapabilityRootDiscovery;
|
|
use codex_exec_server::DiscoverV2CapabilitiesResponse;
|
|
use codex_exec_server::EnvironmentManager;
|
|
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
|
use codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot;
|
|
use codex_exec_server::ExecutorSkill;
|
|
use codex_exec_server::FileSystemEnvironmentAccessor;
|
|
use codex_exec_server::LOCAL_FS;
|
|
use codex_exec_server::SkillDiscoveryRoot;
|
|
use codex_extension_api::ConversationHistory;
|
|
use codex_extension_api::ExtensionData;
|
|
use codex_extension_api::ExtensionEventSink;
|
|
use codex_extension_api::ExtensionMetrics;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_extension_api::ExtensionWarning;
|
|
use codex_extension_api::FunctionCallError;
|
|
use codex_extension_api::NoopTurnItemEmitter;
|
|
use codex_extension_api::PreviousWorldStateSection;
|
|
use codex_extension_api::RenderedWorldStateFragment;
|
|
use codex_extension_api::SelectedPluginSnapshot;
|
|
use codex_extension_api::SkillInvocationInput;
|
|
use codex_extension_api::SkillInvocationKind;
|
|
use codex_extension_api::ThreadStartInput;
|
|
use codex_extension_api::ToolCall;
|
|
use codex_extension_api::ToolCallSource;
|
|
use codex_extension_api::ToolPayload;
|
|
use codex_extension_api::TurnInputContext;
|
|
use codex_extension_api::TurnStartInput;
|
|
use codex_extension_api::WorldStateContributionInput;
|
|
use codex_extension_api::WorldStateSectionContribution;
|
|
use codex_models_manager::model_info::model_info_from_slug;
|
|
use codex_otel::MetricsClient;
|
|
use codex_otel::MetricsConfig;
|
|
use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC;
|
|
use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC;
|
|
use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC;
|
|
use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC;
|
|
use codex_protocol::capabilities::CapabilityRootLocation;
|
|
use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|
use codex_protocol::openai_models::ModelInfo;
|
|
use codex_protocol::protocol::EnvironmentConfigState;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
|
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SkillScope;
|
|
use codex_protocol::protocol::TruncationPolicy;
|
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_skills::SkillMetadata;
|
|
use codex_skills_extension::ExecutorSkillProvider;
|
|
use codex_skills_extension::HostSkillProvider;
|
|
use codex_skills_extension::HostSkillsLoadInput;
|
|
use codex_skills_extension::HostSkillsService;
|
|
use codex_skills_extension::HostSkillsSnapshot;
|
|
use codex_skills_extension::InjectedHostSkillPrompts;
|
|
use codex_skills_extension::SkillLoadOutcome;
|
|
use codex_skills_extension::SkillProviders;
|
|
use codex_skills_extension::SkillsExtensionConfig;
|
|
use codex_skills_extension::catalog::SkillAuthority;
|
|
use codex_skills_extension::catalog::SkillCatalog;
|
|
use codex_skills_extension::catalog::SkillCatalogEntry;
|
|
use codex_skills_extension::catalog::SkillPackageId;
|
|
use codex_skills_extension::catalog::SkillProviderError;
|
|
use codex_skills_extension::catalog::SkillReadResult;
|
|
use codex_skills_extension::catalog::SkillResourceId;
|
|
use codex_skills_extension::catalog::SkillSearchResult;
|
|
use codex_skills_extension::catalog::SkillSourceKind;
|
|
use codex_skills_extension::install;
|
|
use codex_skills_extension::install_with_providers;
|
|
use codex_skills_extension::install_with_providers_and_metrics;
|
|
use codex_skills_extension::provider::SkillListQuery;
|
|
use codex_skills_extension::provider::SkillProvider;
|
|
use codex_skills_extension::provider::SkillProviderFuture;
|
|
use codex_skills_extension::provider::SkillReadRequest;
|
|
use codex_skills_extension::provider::SkillSearchRequest;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
|
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
|
|
use opentelemetry_sdk::metrics::data::MetricData;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
|
|
|
#[path = "skills_extension/shadow_task_context_tests.rs"]
|
|
mod shadow_task_context_tests;
|
|
|
|
#[path = "skills_extension/presentation_dedup_tests.rs"]
|
|
mod presentation_dedup_tests;
|
|
|
|
static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0);
|
|
const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `executor package` locators are owned by their execution environment, `cloud package` locators are opaque package identifiers, and `custom resource` locators use their provider's access mechanism.";
|
|
const DEMO_SKILL_CONTENTS: &str =
|
|
"---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n\nUse the demo skill.\n";
|
|
|
|
fn catalog_model_info() -> ModelInfo {
|
|
ModelInfo {
|
|
context_window: None,
|
|
max_context_window: None,
|
|
include_skills_usage_instructions: false,
|
|
..model_info_from_slug("test-model")
|
|
}
|
|
}
|
|
|
|
fn world_state_section<'a>(
|
|
sections: &'a [WorldStateSectionContribution],
|
|
id: &str,
|
|
) -> &'a WorldStateSectionContribution {
|
|
sections
|
|
.iter()
|
|
.find(|section| section.id() == id)
|
|
.unwrap_or_else(|| panic!("world-state section {id} should exist"))
|
|
}
|
|
|
|
async fn prepare_step(
|
|
registry: &codex_extension_api::ExtensionRegistry<TestConfig>,
|
|
input: codex_extension_api::StepPreparationInput<'_>,
|
|
) {
|
|
for contributor in registry.tool_contributors() {
|
|
contributor.prepare_for_step(input).await;
|
|
}
|
|
}
|
|
|
|
async fn prepared_world_state(
|
|
registry: &codex_extension_api::ExtensionRegistry<TestConfig>,
|
|
ready_selected_capability_roots: &[SelectedCapabilityRoot],
|
|
input: WorldStateContributionInput<'_>,
|
|
) -> Vec<WorldStateSectionContribution> {
|
|
let discovery = input
|
|
.step_store
|
|
.get::<codex_exec_server::ExecutorCapabilityDiscoverySnapshot>();
|
|
let snapshots = input
|
|
.step_store
|
|
.get::<Vec<codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot>>();
|
|
let selected_plugins = input
|
|
.step_store
|
|
.get::<SelectedPluginSnapshot>()
|
|
.unwrap_or_default();
|
|
prepare_step(
|
|
registry,
|
|
codex_extension_api::StepPreparationInput {
|
|
turn_id: input.turn_id,
|
|
ready_selected_capability_roots,
|
|
executor_capability_discovery: discovery.as_deref(),
|
|
executor_capability_discovery_v2: snapshots.as_deref().map(Vec::as_slice),
|
|
selected_plugins: &selected_plugins,
|
|
session_store: input.session_store,
|
|
thread_store: input.thread_store,
|
|
turn_store: input.turn_store,
|
|
step_store: input.step_store,
|
|
},
|
|
)
|
|
.await;
|
|
registry.context_contributors()[0]
|
|
.contribute_world_state(input)
|
|
.await
|
|
}
|
|
|
|
async fn prepared_turn_input(
|
|
registry: &codex_extension_api::ExtensionRegistry<TestConfig>,
|
|
input: TurnInputContext<'_>,
|
|
extension_metrics: Option<Arc<dyn codex_extension_api::ExtensionMetrics>>,
|
|
session_store: &ExtensionData,
|
|
thread_store: &ExtensionData,
|
|
turn_store: &ExtensionData,
|
|
) -> Vec<Box<dyn codex_extension_api::ContextualUserFragment + Send>> {
|
|
let roots = input
|
|
.step_store
|
|
.get::<Vec<codex_exec_server::ResolvedSelectedCapabilityRoot>>()
|
|
.map(|roots| {
|
|
roots
|
|
.iter()
|
|
.map(|root| root.selected_root().clone())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
let discovery = input
|
|
.step_store
|
|
.get::<codex_exec_server::ExecutorCapabilityDiscoverySnapshot>();
|
|
let snapshots = input
|
|
.step_store
|
|
.get::<Vec<codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot>>();
|
|
let selected_plugins = input
|
|
.step_store
|
|
.get::<SelectedPluginSnapshot>()
|
|
.unwrap_or_default();
|
|
prepare_step(
|
|
registry,
|
|
codex_extension_api::StepPreparationInput {
|
|
selected_plugins: &selected_plugins,
|
|
turn_id: &input.turn_id,
|
|
ready_selected_capability_roots: &roots,
|
|
executor_capability_discovery: discovery.as_deref(),
|
|
executor_capability_discovery_v2: snapshots.as_deref().map(Vec::as_slice),
|
|
session_store,
|
|
thread_store,
|
|
turn_store,
|
|
step_store: input.step_store,
|
|
},
|
|
)
|
|
.await;
|
|
registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
input,
|
|
extension_metrics,
|
|
session_store,
|
|
thread_store,
|
|
turn_store,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn skill_world_state_fragments(
|
|
registry: &codex_extension_api::ExtensionRegistry<TestConfig>,
|
|
session_store: &ExtensionData,
|
|
thread_store: &ExtensionData,
|
|
turn_id: &str,
|
|
) -> Result<(RenderedWorldStateFragment, RenderedWorldStateFragment), Box<dyn std::error::Error>> {
|
|
let turn_store = ExtensionData::new(turn_id);
|
|
let selected_roots = [SelectedCapabilityRoot {
|
|
id: "skills".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills")?,
|
|
},
|
|
}];
|
|
let sections = prepared_world_state(
|
|
registry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id,
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store,
|
|
thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let executor = world_state_section(§ions, "skills")
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.ok_or("executor skills should render through world state")?;
|
|
let cloud = world_state_section(§ions, "cloud_skills")
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.ok_or("cloud skills should render through world state")?;
|
|
Ok((executor, cloud))
|
|
}
|
|
|
|
async fn start_registered_turn(
|
|
registry: &codex_extension_api::ExtensionRegistry<TestConfig>,
|
|
session_store: &ExtensionData,
|
|
thread_store: &ExtensionData,
|
|
turn_id: &str,
|
|
) {
|
|
let turn_store = ExtensionData::new(turn_id);
|
|
let mode = codex_protocol::config_types::CollaborationMode {
|
|
mode: codex_protocol::config_types::ModeKind::Default,
|
|
settings: codex_protocol::config_types::Settings {
|
|
model: "test".to_string(),
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
};
|
|
for contributor in registry.turn_lifecycle_contributors() {
|
|
contributor
|
|
.on_turn_start(TurnStartInput {
|
|
turn_id,
|
|
collaboration_mode: &mode,
|
|
token_usage_at_turn_start: None,
|
|
session_store,
|
|
thread_store,
|
|
turn_store: &turn_store,
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn installed_extension_uses_host_service_snapshot() -> TestResult {
|
|
let codex_home = test_codex_home();
|
|
let skill_path = codex_home.join("skills").join("demo").join("SKILL.md");
|
|
std::fs::create_dir_all(
|
|
skill_path
|
|
.parent()
|
|
.ok_or("skill path should have a parent")?,
|
|
)?;
|
|
std::fs::write(&skill_path, DEMO_SKILL_CONTENTS)?;
|
|
let mut config = default_config();
|
|
config.shadow_selection_enabled = true;
|
|
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install(&mut builder, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let session_source = SessionSource::Cli;
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
let skill_path = AbsolutePathBuf::try_from(skill_path)?;
|
|
let skill_path_string = skill_path.to_string_lossy().into_owned();
|
|
let mut outcome = SkillLoadOutcome::default();
|
|
outcome.skills.push(SkillMetadata {
|
|
name: "demo".to_string(),
|
|
description: "Demo skill.".to_string(),
|
|
short_description: None,
|
|
interface: None,
|
|
dependencies: None,
|
|
policy: None,
|
|
path_to_skills_md: PathUri::from_abs_path(&skill_path),
|
|
scope: SkillScope::User,
|
|
plugin_id: None,
|
|
remote_plugin_id: None,
|
|
});
|
|
let loaded_skills = Arc::new(outcome);
|
|
let skill_prompt_path = skill_path_string.replace('\\', "/");
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::clone(&loaded_skills)));
|
|
|
|
let fragments = prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &ExtensionData::new("step"),
|
|
turn_id: "turn-1".to_string(),
|
|
user_input: vec![UserInput::Text {
|
|
text: "$demo".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
environments: Vec::new(),
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&turn_store,
|
|
)
|
|
.await;
|
|
|
|
let expected_catalog = format!(
|
|
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n## Skills\n{SKILLS_INTRO_WITH_ABSOLUTE_PATHS}\n### Available skills\n- demo: Demo skill. (file: {skill_prompt_path})\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
|
|
);
|
|
let expected_skill = format!(
|
|
"<skill>\n<name>demo</name>\n<path>{skill_prompt_path}</path>\n{DEMO_SKILL_CONTENTS}\n</skill>"
|
|
);
|
|
assert_eq!(
|
|
vec![("developer", expected_catalog), ("user", expected_skill),],
|
|
fragments
|
|
.iter()
|
|
.map(|fragment| (fragment.role(), fragment.render().expect_text()))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
let injected_host_skill_prompts = turn_store
|
|
.get::<InjectedHostSkillPrompts>()
|
|
.ok_or("host skill prompt marker should be set")?;
|
|
assert!(injected_host_skill_prompts.contains_path(&skill_path_string));
|
|
|
|
std::fs::remove_dir_all(codex_home)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_world_state_records_catalog_metrics_on_publish_and_change() -> TestResult {
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install(&mut builder, skills_extension_config);
|
|
let registry = builder.build();
|
|
let startup_metrics = Arc::new(RecordingMetrics::default());
|
|
let turn_metrics = Arc::new(RecordingMetrics::default());
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: Some(startup_metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
let skill_path = AbsolutePathBuf::try_from(test_codex_home().join("skills/demo/SKILL.md"))?;
|
|
let mut outcome = SkillLoadOutcome::default();
|
|
outcome.skills.push(SkillMetadata {
|
|
name: "demo".to_string(),
|
|
description: "Demo skill.".to_string(),
|
|
short_description: None,
|
|
interface: None,
|
|
dependencies: None,
|
|
policy: None,
|
|
path_to_skills_md: PathUri::from_abs_path(&skill_path),
|
|
scope: SkillScope::User,
|
|
plugin_id: None,
|
|
remote_plugin_id: None,
|
|
});
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::new(outcome.clone())));
|
|
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: Some(turn_metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(sections.len(), 2);
|
|
let host_section = world_state_section(§ions, "host_skills");
|
|
let (published_snapshot, fragment) =
|
|
host_section.render_diff(PreviousWorldStateSection::Absent);
|
|
let published_snapshot = published_snapshot.unwrap();
|
|
assert!(fragment.is_some());
|
|
let mut expected = expected_catalog_metric_samples("host_world_state", /*count*/ 1);
|
|
assert!(startup_metrics.samples().is_empty());
|
|
assert_eq!(turn_metrics.samples(), expected);
|
|
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: Some(turn_metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
assert!(
|
|
world_state_section(§ions, "host_skills")
|
|
.render_diff(PreviousWorldStateSection::Known(&published_snapshot))
|
|
.1
|
|
.is_none()
|
|
);
|
|
assert!(startup_metrics.samples().is_empty());
|
|
assert_eq!(turn_metrics.samples(), expected);
|
|
|
|
let second_skill_path =
|
|
AbsolutePathBuf::try_from(test_codex_home().join("skills/other/SKILL.md"))?;
|
|
outcome.skills.push(SkillMetadata {
|
|
name: "other".to_string(),
|
|
description: "Other skill.".to_string(),
|
|
short_description: None,
|
|
interface: None,
|
|
dependencies: None,
|
|
policy: None,
|
|
path_to_skills_md: PathUri::from_abs_path(&second_skill_path),
|
|
scope: SkillScope::User,
|
|
plugin_id: None,
|
|
remote_plugin_id: None,
|
|
});
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::new(outcome)));
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: Some(turn_metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
assert!(
|
|
world_state_section(§ions, "host_skills")
|
|
.render_diff(PreviousWorldStateSection::Known(&published_snapshot))
|
|
.1
|
|
.is_some()
|
|
);
|
|
expected.extend(expected_catalog_metric_samples(
|
|
"host_world_state",
|
|
/*count*/ 2,
|
|
));
|
|
assert!(startup_metrics.samples().is_empty());
|
|
assert_eq!(turn_metrics.samples(), expected);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn persisted_host_snapshot_deduplicates_warning_after_reinitialization() -> TestResult {
|
|
let (event_tx, event_rx) = std::sync::mpsc::channel();
|
|
let mut builder =
|
|
ExtensionRegistryBuilder::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
|
|
install(&mut builder, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let config = default_config();
|
|
let skill_path = AbsolutePathBuf::try_from(test_codex_home().join("skills/demo/SKILL.md"))?;
|
|
let mut outcome = SkillLoadOutcome::default();
|
|
outcome.skills.push(SkillMetadata {
|
|
name: "demo".to_string(),
|
|
description: "Demo skill.".to_string(),
|
|
short_description: None,
|
|
interface: None,
|
|
dependencies: None,
|
|
policy: None,
|
|
path_to_skills_md: PathUri::from_abs_path(&skill_path),
|
|
scope: SkillScope::User,
|
|
plugin_id: None,
|
|
remote_plugin_id: None,
|
|
});
|
|
let host_snapshot = HostSkillsSnapshot::new(Arc::new(outcome));
|
|
|
|
let thread_store = ExtensionData::new("thread");
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
let mut model_info = model_info_from_slug("test-model");
|
|
model_info.context_window = Some(50);
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
turn_store.insert(host_snapshot.clone());
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let host_section = world_state_section(§ions, "host_skills");
|
|
let (published_snapshot, fragment) =
|
|
host_section.render_diff(PreviousWorldStateSection::Absent);
|
|
let published_snapshot = published_snapshot.unwrap();
|
|
assert!(fragment.is_some());
|
|
event_rx.try_recv()?.into_warning();
|
|
assert!(event_rx.try_recv().is_err());
|
|
|
|
let resumed_thread_store = ExtensionData::new("thread");
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &resumed_thread_store,
|
|
})
|
|
.await;
|
|
let mut model_info = model_info_from_slug("test-model");
|
|
model_info.context_window = Some(50);
|
|
let resumed_turn_store = ExtensionData::new("turn-2");
|
|
resumed_turn_store.insert(host_snapshot);
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-2",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &resumed_thread_store,
|
|
turn_store: &resumed_turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
assert!(
|
|
world_state_section(§ions, "host_skills")
|
|
.render_diff(PreviousWorldStateSection::Known(&published_snapshot))
|
|
.1
|
|
.is_none()
|
|
);
|
|
assert!(event_rx.try_recv().is_err());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn executor_cloud_and_host_share_catalog_world_state_flow() -> TestResult {
|
|
let provider = |mut entry: SkillCatalogEntry| {
|
|
if matches!(
|
|
entry.authority.kind,
|
|
SkillSourceKind::Cloud | SkillSourceKind::Host
|
|
) {
|
|
entry.name = "shared-skill".to_string();
|
|
}
|
|
Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![entry],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
})
|
|
};
|
|
let providers = SkillProviders::new()
|
|
.with_executor_provider(provider(test_entry(
|
|
SkillSourceKind::Executor,
|
|
"env-1",
|
|
"executor/executor-skill",
|
|
"skill://executor/executor-skill/SKILL.md",
|
|
)))
|
|
.with_cloud_provider(provider(
|
|
test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"codex_apps",
|
|
"cloud/cloud-skill",
|
|
"skill://cloud/cloud-skill/SKILL.md",
|
|
)
|
|
.with_remote_plugin_id(Some("shared-plugin".to_string())),
|
|
))
|
|
.with_host_provider(provider(
|
|
test_entry(
|
|
SkillSourceKind::Host,
|
|
"host",
|
|
"host/host-skill",
|
|
"/skills/host-skill/SKILL.md",
|
|
)
|
|
.with_display_path("/skills/host-skill/SKILL.md")
|
|
.with_remote_plugin_id(Some("shared-plugin".to_string())),
|
|
));
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &default_config(),
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
// Host rendering stays separate even when its identity matches a Cloud skill.
|
|
start_registered_turn(®istry, &session_store, &thread_store, "turn-1").await;
|
|
|
|
let selected_roots = [SelectedCapabilityRoot {
|
|
id: "skills".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills")?,
|
|
},
|
|
}];
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::new(
|
|
SkillLoadOutcome::default(),
|
|
)));
|
|
let metrics = Arc::new(RecordingMetrics::default());
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: Some(metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
sections
|
|
.iter()
|
|
.map(WorldStateSectionContribution::id)
|
|
.collect::<Vec<_>>(),
|
|
vec!["skills", "cloud_skills", "host_skills"]
|
|
);
|
|
assert!(metrics.samples().is_empty());
|
|
|
|
for (section_id, expected_line) in [
|
|
(
|
|
"skills",
|
|
"- executor-skill: Fix lint errors. (executor package: executor/executor-skill)",
|
|
),
|
|
(
|
|
"cloud_skills",
|
|
"- shared-skill: Fix lint errors. (cloud package: cloud/cloud-skill)",
|
|
),
|
|
(
|
|
"host_skills",
|
|
"- shared-skill: Fix lint errors. (file: /skills/host-skill/SKILL.md)",
|
|
),
|
|
] {
|
|
let fragment = world_state_section(§ions, section_id)
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.ok_or("skill catalog should render through world state")?;
|
|
let body = fragment.body().clone().expect_text();
|
|
assert!(body.contains(expected_line));
|
|
}
|
|
|
|
let expected_metrics = [
|
|
"executor_world_state",
|
|
"cloud_world_state",
|
|
"host_world_state",
|
|
]
|
|
.into_iter()
|
|
.flat_map(|surface| expected_catalog_metric_samples(surface, /*count*/ 1))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(metrics.samples(), expected_metrics);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nonempty_executor_empty_host_records_catalog_metrics() -> TestResult {
|
|
let executor_provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![test_entry(
|
|
SkillSourceKind::Executor,
|
|
"env-1",
|
|
"executor/lint-fix",
|
|
"lint-fix/SKILL.md",
|
|
)],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
});
|
|
let providers = SkillProviders::new()
|
|
.with_host_provider(Arc::new(HostSkillProvider::new()))
|
|
.with_executor_provider(executor_provider);
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let metrics = Arc::new(RecordingMetrics::default());
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
let selected_roots = vec![SelectedCapabilityRoot {
|
|
id: "lint-fix".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills/lint-fix").expect("skill root URI"),
|
|
},
|
|
}];
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::new(
|
|
SkillLoadOutcome::default(),
|
|
)));
|
|
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: Some(metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(sections.len(), 2);
|
|
assert!(
|
|
sections[0]
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
world_state_section(§ions, "host_skills")
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.is_none()
|
|
);
|
|
let expected = expected_catalog_metric_samples("executor_world_state", /*count*/ 1);
|
|
assert_eq!(metrics.samples(), expected);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_world_state_uses_provider_catalog_with_core_compatible_rendering() -> TestResult {
|
|
let list_calls = Arc::new(AtomicUsize::new(0));
|
|
let provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![
|
|
test_entry(
|
|
SkillSourceKind::Host,
|
|
"host",
|
|
"host/provider-skill",
|
|
"provider-skill/SKILL.md",
|
|
)
|
|
.with_short_description(Some("Short description.".to_string())),
|
|
],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: Some(Arc::clone(&list_calls)),
|
|
fail_first_list: false,
|
|
});
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(
|
|
&mut builder,
|
|
SkillProviders::new().with_host_provider(provider),
|
|
skills_extension_config,
|
|
);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::new(
|
|
SkillLoadOutcome::default(),
|
|
)));
|
|
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let host_fragment = world_state_section(§ions, "host_skills")
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.ok_or("host provider catalog should render")?;
|
|
|
|
let body = host_fragment.body().clone().expect_text();
|
|
assert!(body.contains("Fix lint errors."));
|
|
assert!(!body.contains("Short description."));
|
|
assert_eq!(1, list_calls.load(Ordering::Relaxed));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shadow_selection_uses_host_catalog_when_instructions_are_disabled() -> TestResult {
|
|
let list_calls = Arc::new(AtomicUsize::new(0));
|
|
let provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![test_entry(
|
|
SkillSourceKind::Host,
|
|
"host",
|
|
"host/lint-fix",
|
|
"lint-fix/SKILL.md",
|
|
)],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: Some(Arc::clone(&list_calls)),
|
|
fail_first_list: false,
|
|
});
|
|
let metrics = MetricsClient::new(
|
|
MetricsConfig::in_memory(
|
|
"test",
|
|
"codex-skills-extension",
|
|
env!("CARGO_PKG_VERSION"),
|
|
InMemoryMetricExporter::default(),
|
|
)
|
|
.with_runtime_reader(),
|
|
)?;
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers_and_metrics(
|
|
&mut builder,
|
|
SkillProviders::new().with_host_provider(provider),
|
|
Some(metrics.clone()),
|
|
skills_extension_config,
|
|
);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let mut config = default_config();
|
|
config.include_instructions = false;
|
|
config.shadow_selection_enabled = true;
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
let step_store = ExtensionData::new("step");
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::new(
|
|
SkillLoadOutcome::default(),
|
|
)));
|
|
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let fragments = prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &step_store,
|
|
turn_id: "turn-1".to_string(),
|
|
user_input: vec![UserInput::Text {
|
|
text: "Fix lint errors.".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
environments: Vec::new(),
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&turn_store,
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
world_state_section(§ions, "host_skills")
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.is_none()
|
|
);
|
|
assert!(fragments.is_empty());
|
|
let snapshots = shadow_task_context_tests::collect_shadow_observations(
|
|
&metrics,
|
|
"codex.skills.shadow_selection.catalog_entries",
|
|
/*expected*/ 12,
|
|
)
|
|
.await?;
|
|
let catalog_entry_counts = snapshots
|
|
.iter()
|
|
.flat_map(opentelemetry_sdk::metrics::data::ResourceMetrics::scope_metrics)
|
|
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
|
|
.filter(|metric| metric.name() == "codex.skills.shadow_selection.catalog_entries")
|
|
.flat_map(|metric| match metric.data() {
|
|
AggregatedMetrics::F64(MetricData::Histogram(histogram)) => histogram
|
|
.data_points()
|
|
.map(opentelemetry_sdk::metrics::data::HistogramDataPoint::sum),
|
|
data => panic!("unexpected shadow catalog metric data: {data:?}"),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
catalog_entry_counts.iter().all(|count| *count == 1.0),
|
|
"every shadow selector should see the cached host skill: {catalog_entry_counts:?}"
|
|
);
|
|
assert_eq!(1, list_calls.load(Ordering::Relaxed));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shadow_lru_selector_recovers_a_skill_invoked_on_an_earlier_turn() -> TestResult {
|
|
let provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![test_entry(
|
|
SkillSourceKind::Host,
|
|
"host",
|
|
"host/lint-fix",
|
|
"lint-fix/SKILL.md",
|
|
)],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
});
|
|
let metrics = MetricsClient::new(
|
|
MetricsConfig::in_memory(
|
|
"test",
|
|
"codex-skills-extension",
|
|
env!("CARGO_PKG_VERSION"),
|
|
InMemoryMetricExporter::default(),
|
|
)
|
|
.with_runtime_reader(),
|
|
)?;
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers_and_metrics(
|
|
&mut builder,
|
|
SkillProviders::new().with_host_provider(provider),
|
|
Some(metrics.clone()),
|
|
skills_extension_config,
|
|
);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let mut config = default_config();
|
|
config.include_instructions = false;
|
|
config.shadow_selection_enabled = true;
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
for (turn_id, text) in [("turn-1", "Fix lint errors."), ("turn-2", "continue")] {
|
|
let turn_store = ExtensionData::new(turn_id);
|
|
let fragments = prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &ExtensionData::new("step"),
|
|
turn_id: turn_id.to_string(),
|
|
user_input: vec![UserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
environments: vec![codex_extension_api::TurnInputEnvironment {
|
|
environment_id: "test".to_string(),
|
|
cwd: PathUri::from_host_native_path(std::env::current_dir().expect("test cwd"))
|
|
.expect("absolute cwd"),
|
|
is_primary: true,
|
|
fs: &FileSystemEnvironmentAccessor::unrestricted(&LOCAL_FS),
|
|
}],
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&turn_store,
|
|
)
|
|
.await;
|
|
assert!(fragments.is_empty());
|
|
registry.skill_invocation_contributors()[0]
|
|
.on_skill_invocation(SkillInvocationInput {
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
turn_id,
|
|
skill_resource: "lint-fix/SKILL.md",
|
|
kind: SkillInvocationKind::Implicit,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
let snapshots = shadow_task_context_tests::collect_shadow_observations(
|
|
&metrics,
|
|
"codex.skills.shadow_selection.invocation",
|
|
/*expected*/ 24,
|
|
)
|
|
.await?;
|
|
let selector_hits = snapshots
|
|
.iter()
|
|
.flat_map(opentelemetry_sdk::metrics::data::ResourceMetrics::scope_metrics)
|
|
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
|
|
.filter(|metric| metric.name() == "codex.skills.shadow_selection.invocation")
|
|
.flat_map(|metric| match metric.data() {
|
|
AggregatedMetrics::U64(MetricData::Sum(sum)) => sum.data_points(),
|
|
data => panic!("unexpected shadow invocation metric data: {data:?}"),
|
|
})
|
|
.filter_map(|point| {
|
|
let method = point
|
|
.attributes()
|
|
.find(|attribute| attribute.key.as_str() == "method")?
|
|
.value
|
|
.as_str();
|
|
if !matches!(
|
|
method.as_ref(),
|
|
"lru_v1"
|
|
| "lru_plus_lexical_v1"
|
|
| "lru_plus_character_routing_v1"
|
|
| "lru_plus_lexical_character_routing_v1"
|
|
) {
|
|
return None;
|
|
}
|
|
let hit = point
|
|
.attributes()
|
|
.find(|attribute| attribute.key.as_str() == "hit")?
|
|
.value
|
|
.as_str()
|
|
.to_string();
|
|
Some((method.to_string(), hit, point.value()))
|
|
})
|
|
.fold(
|
|
std::collections::BTreeMap::new(),
|
|
|mut totals, (method, hit, count)| {
|
|
*totals.entry((method, hit)).or_insert(0) += count;
|
|
totals
|
|
},
|
|
)
|
|
.into_iter()
|
|
.map(|((method, hit), count)| (method, hit, count))
|
|
.collect::<Vec<_>>();
|
|
|
|
assert_eq!(
|
|
vec![
|
|
(
|
|
"lru_plus_character_routing_v1".to_string(),
|
|
"true".to_string(),
|
|
2,
|
|
),
|
|
(
|
|
"lru_plus_lexical_character_routing_v1".to_string(),
|
|
"true".to_string(),
|
|
2,
|
|
),
|
|
("lru_plus_lexical_v1".to_string(), "true".to_string(), 2),
|
|
("lru_v1".to_string(), "false".to_string(), 1),
|
|
("lru_v1".to_string(), "true".to_string(), 1),
|
|
],
|
|
selector_hits
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn selected_executor_catalog_caches_root_selections_per_turn_and_reuses_discovery_snapshots()
|
|
-> TestResult {
|
|
let step_store = ExtensionData::new("step-1");
|
|
let read_requests = Arc::new(Mutex::new(Vec::new()));
|
|
let list_calls = Arc::new(AtomicUsize::new(0));
|
|
let resource = SkillResourceId::environment(
|
|
"lint-fix/SKILL.md",
|
|
"env-1",
|
|
PathUri::parse("file:///skills/lint-fix/SKILL.md")?,
|
|
);
|
|
let mut entry = test_entry(
|
|
SkillSourceKind::Executor,
|
|
"env-1",
|
|
"executor/lint-fix",
|
|
"lint-fix/SKILL.md",
|
|
);
|
|
entry.main_prompt = resource.clone();
|
|
let executor_provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![entry],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::clone(&read_requests),
|
|
list_calls: Some(Arc::clone(&list_calls)),
|
|
fail_first_list: false,
|
|
});
|
|
let providers = SkillProviders::new().with_executor_provider(executor_provider);
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let selected_roots = vec![SelectedCapabilityRoot {
|
|
id: "lint-fix".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills/lint-fix").expect("skill root URI"),
|
|
},
|
|
}];
|
|
let session_source = SessionSource::Cli;
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
let prompt_fragments = registry.context_contributors()[0]
|
|
.contribute_thread_context(&session_store, &thread_store)
|
|
.await;
|
|
assert!(prompt_fragments.is_empty());
|
|
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
let turn_environment = TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
environment_id: "env-1".to_string(),
|
|
cwd: PathUri::parse("file:///workspace").expect("cwd URI"),
|
|
workspace_roots: Vec::new(),
|
|
config: EnvironmentConfigState::FromThread,
|
|
};
|
|
let available_sections = prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: std::slice::from_ref(&turn_environment),
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(1, available_sections.len());
|
|
let (available_snapshot, available_fragment) =
|
|
available_sections[0].render_diff(PreviousWorldStateSection::Absent);
|
|
let available_snapshot = available_snapshot.unwrap();
|
|
let available_fragment = available_fragment.ok_or("available skills should render")?;
|
|
let available_body = available_fragment.body().clone().expect_text();
|
|
assert!(available_body.contains("lint-fix"));
|
|
assert!(available_body.contains("(executor package: executor/lint-fix)"));
|
|
|
|
let fragments = prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &step_store,
|
|
turn_id: "turn-1".to_string(),
|
|
user_input: vec![UserInput::Text {
|
|
text: "$lint-fix please".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
environments: vec![codex_extension_api::TurnInputEnvironment {
|
|
environment_id: turn_environment.environment_id.clone(),
|
|
cwd: turn_environment.cwd.clone(),
|
|
is_primary: true,
|
|
fs: &FileSystemEnvironmentAccessor::unrestricted(&LOCAL_FS),
|
|
}],
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&turn_store,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(1, fragments.len());
|
|
assert_eq!("user", fragments[0].role());
|
|
let rendered = fragments[0].render().expect_text();
|
|
assert!(rendered.contains("<name>lint-fix</name>"));
|
|
assert!(rendered.contains("# Lint Fix"));
|
|
assert_eq!(
|
|
vec![(
|
|
SkillAuthority::new(SkillSourceKind::Executor, "env-1"),
|
|
SkillPackageId("executor/lint-fix".to_string()),
|
|
resource,
|
|
)],
|
|
read_request_keys(&read_requests)
|
|
);
|
|
let available_world_state =
|
|
serde_json::Map::from_iter([("skills".to_string(), available_snapshot.clone())]);
|
|
let unavailable_turn_store = ExtensionData::new("turn-2");
|
|
let unavailable_sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: Some(&available_world_state),
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-2",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &unavailable_turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let (unavailable_snapshot, unavailable_fragment) =
|
|
unavailable_sections[0].render_diff(PreviousWorldStateSection::Known(&available_snapshot));
|
|
let unavailable_snapshot = unavailable_snapshot.unwrap();
|
|
assert_eq!(
|
|
unavailable_snapshot,
|
|
serde_json::json!({
|
|
"body": null,
|
|
"includeInstructions": true,
|
|
"lastAvailableFingerprint": blake3::hash(
|
|
available_snapshot["body"].as_str().ok_or("available catalog")?.as_bytes()
|
|
).to_hex().to_string(),
|
|
}),
|
|
);
|
|
let unavailable_fragment = unavailable_fragment.ok_or("removed skills should render")?;
|
|
let unavailable_body = unavailable_fragment.body().clone().expect_text();
|
|
assert!(unavailable_body.contains("No selected-environment skills"));
|
|
|
|
let unavailable_world_state =
|
|
serde_json::Map::from_iter([("skills".to_string(), unavailable_snapshot.clone())]);
|
|
let restored_turn_store = ExtensionData::new("turn-3");
|
|
let restored_sections = prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: Some(&unavailable_world_state),
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-3",
|
|
environments: &[turn_environment],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &restored_turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let (restored_snapshot, restored_fragment) =
|
|
restored_sections[0].render_diff(PreviousWorldStateSection::Known(&unavailable_snapshot));
|
|
let restored_snapshot = restored_snapshot.unwrap();
|
|
let restored_fragment = restored_fragment.ok_or("restored skills should render")?;
|
|
assert_eq!(
|
|
restored_fragment.body().clone().expect_text(),
|
|
"\n## Skills update\nThe previously listed selected-environment skills are available again.\n",
|
|
);
|
|
assert!(restored_sections[0].matches_retained_fragment("developer", &available_body));
|
|
assert!(!restored_sections[0].matches_retained_fragment("developer", &unavailable_body));
|
|
assert_eq!(
|
|
restored_sections[0]
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1,
|
|
Some(available_fragment),
|
|
);
|
|
assert_eq!(2, list_calls.load(Ordering::Relaxed));
|
|
|
|
// Retained steps keep their catalog even after the selected roots change.
|
|
let retained_sections = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
})
|
|
.await;
|
|
assert_eq!(
|
|
retained_sections[0]
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.0
|
|
.as_ref(),
|
|
Some(&available_snapshot)
|
|
);
|
|
assert_eq!(2, list_calls.load(Ordering::Relaxed));
|
|
|
|
let failed_discovery = ExecutorCapabilityDiscoverySnapshot::new(
|
|
&selected_roots,
|
|
vec![Err("exec-server transport disconnected".to_string())],
|
|
Default::default(),
|
|
);
|
|
let recovered_discovery = ExecutorCapabilityDiscoverySnapshot::new(
|
|
&selected_roots,
|
|
vec![Ok(Arc::new(CapabilityRootDiscovery {
|
|
id: "lint-fix".to_string(),
|
|
path: PathUri::parse("file:///skills/lint-fix")?,
|
|
plugin: None,
|
|
skills: Vec::new(),
|
|
namespace_manifests: Vec::new(),
|
|
warnings: Vec::new(),
|
|
error: None,
|
|
}))],
|
|
Default::default(),
|
|
);
|
|
for (turn_id, discovery, expected_list_calls) in [
|
|
("failed-discovery", &failed_discovery, 3),
|
|
("recovered-discovery", &recovered_discovery, 4),
|
|
("cached-discovery", &recovered_discovery, 4),
|
|
] {
|
|
let step_store = ExtensionData::new("step");
|
|
step_store.insert(discovery.clone());
|
|
prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id,
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &ExtensionData::new(turn_id),
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(expected_list_calls, list_calls.load(Ordering::Relaxed));
|
|
}
|
|
|
|
let mut listing_disabled_config = config.clone();
|
|
listing_disabled_config.include_instructions = false;
|
|
registry.config_contributors()[0].on_config_changed(
|
|
&session_store,
|
|
&thread_store,
|
|
&config,
|
|
&listing_disabled_config,
|
|
);
|
|
let listing_disabled_turn_store = ExtensionData::new("turn-4");
|
|
let listing_disabled_sections = prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-4",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &listing_disabled_turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let (listing_disabled_snapshot, listing_disabled_fragment) = listing_disabled_sections[0]
|
|
.render_diff(PreviousWorldStateSection::Known(&restored_snapshot));
|
|
let listing_disabled_fragment =
|
|
listing_disabled_fragment.ok_or("disabled skill listing should render")?;
|
|
assert_eq!(
|
|
"\n## Skills update\nSelected-environment skills are not listed automatically. Explicit skill mentions can still be resolved when available.\n",
|
|
listing_disabled_fragment.body().clone().expect_text()
|
|
);
|
|
let mut normalized_listing_disabled_snapshot = listing_disabled_snapshot.unwrap();
|
|
normalized_listing_disabled_snapshot
|
|
.as_object_mut()
|
|
.ok_or("skills snapshot should be an object")?
|
|
.remove("body");
|
|
assert!(
|
|
listing_disabled_sections[0]
|
|
.render_diff(PreviousWorldStateSection::Known(
|
|
&normalized_listing_disabled_snapshot
|
|
))
|
|
.1
|
|
.is_none()
|
|
);
|
|
|
|
let first_root = selected_roots[0].clone();
|
|
let second_root = SelectedCapabilityRoot {
|
|
id: "second-root".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills/second-root")?,
|
|
},
|
|
};
|
|
let cache_environment = Arc::new(codex_exec_server::Environment::create_for_tests(
|
|
/*exec_server_url*/ None,
|
|
)?);
|
|
let cache_manager = EnvironmentManager::default_for_tests();
|
|
let previous_list_calls = list_calls.load(Ordering::Relaxed);
|
|
let mut current_config = listing_disabled_config;
|
|
for (turn_id, roots, include_bundled_skills, expected_new_calls) in [
|
|
("cache-turn-1", vec![first_root.clone()], true, 1),
|
|
("cache-turn-1", vec![first_root.clone()], true, 1),
|
|
(
|
|
"cache-turn-1",
|
|
vec![first_root.clone(), second_root.clone()],
|
|
true,
|
|
2,
|
|
),
|
|
(
|
|
"cache-turn-1",
|
|
vec![second_root.clone(), first_root.clone()],
|
|
true,
|
|
3,
|
|
),
|
|
("cache-turn-1", vec![first_root.clone()], true, 4),
|
|
("cache-turn-2", vec![first_root.clone()], true, 5),
|
|
("cache-turn-2", vec![first_root.clone()], false, 6),
|
|
("cache-turn-2", vec![first_root], false, 6),
|
|
] {
|
|
let mut next_config = current_config.clone();
|
|
next_config.bundled_skills_enabled = include_bundled_skills;
|
|
registry.config_contributors()[0].on_config_changed(
|
|
&session_store,
|
|
&thread_store,
|
|
¤t_config,
|
|
&next_config,
|
|
);
|
|
current_config = next_config;
|
|
let cache_step = ExtensionData::new("cache-step");
|
|
let captured = HashMap::from([("env-1".to_string(), Some(Arc::clone(&cache_environment)))]);
|
|
cache_step.insert(
|
|
cache_manager
|
|
.resolve_selected_capability_roots(&roots, &captured)
|
|
.await,
|
|
);
|
|
prepared_world_state(
|
|
®istry,
|
|
&roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &cache_step,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id,
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &ExtensionData::new(turn_id),
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
previous_list_calls + expected_new_calls,
|
|
list_calls.load(Ordering::Relaxed)
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn overlapping_executor_catalogs_render_their_own_roots() -> TestResult {
|
|
use tracing::Subscriber;
|
|
use tracing::instrument::WithSubscriber;
|
|
use tracing_subscriber::Layer;
|
|
use tracing_subscriber::layer::Context;
|
|
use tracing_subscriber::layer::SubscriberExt;
|
|
use tracing_subscriber::registry::LookupSpan;
|
|
|
|
// Pause after A's refresh returns, before World State can read the catalog. Pausing the
|
|
// provider would be too early: A would still publish its result after B finishes.
|
|
struct PauseAfterRefresh {
|
|
entered: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
|
resume: Mutex<std::sync::mpsc::Receiver<()>>,
|
|
}
|
|
|
|
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for PauseAfterRefresh {
|
|
fn on_close(&self, id: tracing::span::Id, context: Context<'_, S>) {
|
|
if context
|
|
.span(&id)
|
|
.is_some_and(|span| span.name() == "skills.executor.refresh_executor_catalog")
|
|
&& let Some(entered) = self.entered.lock().unwrap().take()
|
|
{
|
|
entered.send(()).unwrap();
|
|
self.resume
|
|
.lock()
|
|
.unwrap()
|
|
.recv_timeout(std::time::Duration::from_secs(/*secs*/ 10))
|
|
.expect("B should finish before A reads its catalog");
|
|
}
|
|
}
|
|
}
|
|
|
|
let temp = tempfile::tempdir()?;
|
|
let root = |name: &str| -> Result<SelectedCapabilityRoot, Box<dyn std::error::Error>> {
|
|
let path = temp.path().join(name);
|
|
std::fs::create_dir_all(path.join(name))?;
|
|
std::fs::write(
|
|
path.join(name).join("SKILL.md"),
|
|
DEMO_SKILL_CONTENTS.replace("demo", name),
|
|
)?;
|
|
Ok(SelectedCapabilityRoot {
|
|
id: name.to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
|
|
path: PathUri::from_host_native_path(path)?,
|
|
},
|
|
})
|
|
};
|
|
let root_a = root("skill-a")?;
|
|
let root_b = root("skill-b")?;
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
let provider = codex_skills_extension::ExecutorSkillProvider::new_with_restriction_product(
|
|
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
|
/*restriction_product*/ None,
|
|
);
|
|
install_with_providers(
|
|
&mut builder,
|
|
SkillProviders::new().with_executor_provider(Arc::new(provider)),
|
|
skills_extension_config,
|
|
);
|
|
let registry = Arc::new(builder.build());
|
|
let session_store = Arc::new(ExtensionData::new("session"));
|
|
let thread_store = Arc::new(ExtensionData::new("thread"));
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &default_config(),
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
let thread_id = codex_protocol::ThreadId::new();
|
|
let render = |root: SelectedCapabilityRoot| {
|
|
let registry = Arc::clone(®istry);
|
|
let session_store = Arc::clone(&session_store);
|
|
let thread_store = Arc::clone(&thread_store);
|
|
async move {
|
|
let step_store = ExtensionData::new(&root.id);
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[root],
|
|
WorldStateContributionInput {
|
|
model_info: &catalog_model_info(),
|
|
thread_id,
|
|
turn_id: "turn",
|
|
environments: &[],
|
|
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &step_store,
|
|
step_store: &step_store,
|
|
previous_world_state: None,
|
|
},
|
|
)
|
|
.await;
|
|
world_state_section(§ions, "skills")
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.expect("executor skills should render")
|
|
.body()
|
|
.clone()
|
|
.expect_text()
|
|
}
|
|
};
|
|
let (entered, paused) = tokio::sync::oneshot::channel();
|
|
let (release, resume) = std::sync::mpsc::channel();
|
|
// Keep tracing aware of uninstrumented threads so a parallel test cannot register
|
|
// the refresh callsite as globally disabled before this subscriber reaches it.
|
|
let _interest_cache_guard =
|
|
tracing::Dispatch::new(tracing::subscriber::NoSubscriber::default());
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
PauseAfterRefresh {
|
|
entered: Mutex::new(Some(entered)),
|
|
resume: Mutex::new(resume),
|
|
}
|
|
.with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
|
|
metadata.name() == "skills.executor.refresh_executor_catalog"
|
|
})),
|
|
);
|
|
let a = tokio::spawn(render(root_a).with_subscriber(subscriber));
|
|
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), paused).await??;
|
|
let b = render(root_b).await;
|
|
release.send(())?;
|
|
let a = a.await?;
|
|
assert!(a.contains("skill-a") && !a.contains("skill-b"), "{a}");
|
|
assert!(b.contains("skill-b") && !b.contains("skill-a"), "{b}");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_context_truncates_catalog_descriptions() -> TestResult {
|
|
let description = "x".repeat(1_025);
|
|
// A small cloud catalog leaves its unused allowance to filesystem descriptions.
|
|
let executor_entries = (0..5)
|
|
.map(|index| {
|
|
let package_id = format!("executor/executor-long-description-{index}");
|
|
let mut entry = test_entry(
|
|
SkillSourceKind::Executor,
|
|
"env-1",
|
|
&package_id,
|
|
&format!("skill://{package_id}/SKILL.md"),
|
|
);
|
|
entry.description = description.clone();
|
|
entry
|
|
})
|
|
.collect();
|
|
let mut cloud_entry = test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"codex_apps",
|
|
"cloud/cloud-long-description",
|
|
"skill://cloud/cloud-long-description/SKILL.md",
|
|
);
|
|
cloud_entry.description = description.clone();
|
|
let providers = SkillProviders::new()
|
|
.with_executor_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: executor_entries,
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}))
|
|
.with_cloud_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![cloud_entry],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}));
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let session_source = SessionSource::Cli;
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
start_registered_turn(®istry, &session_store, &thread_store, "turn-1").await;
|
|
|
|
let (executor, cloud) =
|
|
skill_world_state_fragments(®istry, &session_store, &thread_store, "turn-1").await?;
|
|
let executor = executor.body().clone().expect_text();
|
|
let cloud = cloud.body().clone().expect_text();
|
|
let expected_description = "x".repeat(1_021) + "...";
|
|
for index in 0..5 {
|
|
assert!(executor.contains(&format!(
|
|
"- executor-long-description-{index}: {expected_description} (executor package:"
|
|
)));
|
|
}
|
|
assert!(cloud.contains("- cloud-long-description:"));
|
|
for rendered in [executor, cloud] {
|
|
assert!(rendered.contains(&expected_description));
|
|
assert!(!rendered.contains(&"x".repeat(1_024)));
|
|
assert!(!rendered.contains(&description));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn catalog_rebalances_only_to_avoid_omissions_and_retains_the_allocation() -> TestResult {
|
|
enum Phase {
|
|
Initial,
|
|
FirstReady,
|
|
Disconnected,
|
|
Reconnected,
|
|
Resumed,
|
|
CloudCatalogGrew,
|
|
BudgetChanged,
|
|
}
|
|
|
|
// Description-only pressure, filesystem omissions that can be rescued, and
|
|
// unavoidable omissions. Check both token and fallback character accounting.
|
|
for (executor_count, cloud_count, rebalance, all_fit) in [
|
|
(17, 82, false, true),
|
|
(40, 5, true, true),
|
|
(40, 160, false, false),
|
|
] {
|
|
for context_window in [None, Some(100_000)] {
|
|
let description = "x".repeat(1_025);
|
|
let executor_entries: Vec<_> = (0..executor_count)
|
|
.map(|index| {
|
|
let package_id = format!("executor/executor-skill-{index:02}");
|
|
let mut entry = test_entry(
|
|
SkillSourceKind::Executor,
|
|
"env-1",
|
|
&package_id,
|
|
&format!("skill://{package_id}/SKILL.md"),
|
|
);
|
|
entry.description = description.clone();
|
|
entry
|
|
})
|
|
.collect();
|
|
let registry_for_cloud_count = |cloud_count| {
|
|
let cloud_entries = (0..cloud_count)
|
|
.map(|index| {
|
|
let package_id = format!("cloud/cloud-skill-{index:02}");
|
|
let mut entry = test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"codex_apps",
|
|
&package_id,
|
|
&format!("skill://{package_id}/SKILL.md"),
|
|
);
|
|
entry.description = description.clone();
|
|
entry
|
|
})
|
|
.collect();
|
|
let providers = SkillProviders::new()
|
|
.with_executor_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: executor_entries.clone(),
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}))
|
|
.with_cloud_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: cloud_entries,
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}));
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
builder.build()
|
|
};
|
|
let mut registry = registry_for_cloud_count(cloud_count);
|
|
|
|
let selected_roots = [SelectedCapabilityRoot {
|
|
id: "skills".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills")?,
|
|
},
|
|
}];
|
|
let mut model = ModelInfo {
|
|
context_window,
|
|
..catalog_model_info()
|
|
};
|
|
let session_store = ExtensionData::new("session");
|
|
let mut thread_store = ExtensionData::new("thread");
|
|
let mut previous = serde_json::Map::new();
|
|
let mut initial_cloud = None;
|
|
let mut accepted_cloud = None;
|
|
let mut phases = vec![
|
|
Phase::Initial,
|
|
Phase::FirstReady,
|
|
Phase::Disconnected,
|
|
Phase::Reconnected,
|
|
Phase::Disconnected,
|
|
Phase::Reconnected,
|
|
Phase::Resumed,
|
|
];
|
|
if rebalance {
|
|
phases.extend([
|
|
Phase::CloudCatalogGrew,
|
|
Phase::Disconnected,
|
|
Phase::Reconnected,
|
|
Phase::Resumed,
|
|
]);
|
|
}
|
|
phases.push(Phase::BudgetChanged);
|
|
for (step, phase) in phases.into_iter().enumerate() {
|
|
if matches!(phase, Phase::CloudCatalogGrew) {
|
|
// Refresh the cloud inventory on resume while retaining the saved allocation.
|
|
registry = registry_for_cloud_count(/*cloud_count*/ 15);
|
|
}
|
|
if matches!(
|
|
phase,
|
|
Phase::Initial | Phase::Resumed | Phase::CloudCatalogGrew
|
|
) {
|
|
thread_store = ExtensionData::new("thread");
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &default_config(),
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
}
|
|
if matches!(phase, Phase::BudgetChanged) {
|
|
model.context_window = Some(200_000);
|
|
}
|
|
let ready = matches!(
|
|
phase,
|
|
Phase::FirstReady
|
|
| Phase::Reconnected
|
|
| Phase::Resumed
|
|
| Phase::CloudCatalogGrew
|
|
);
|
|
let turn_id = format!("turn-{step}");
|
|
let turn_store = ExtensionData::new(&turn_id);
|
|
start_registered_turn(®istry, &session_store, &thread_store, &turn_id).await;
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
if ready { &selected_roots } else { &[] },
|
|
WorldStateContributionInput {
|
|
previous_world_state: Some(&previous),
|
|
model_info: &model,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: &turn_id,
|
|
environments: &[],
|
|
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let rendered = sections
|
|
.iter()
|
|
.map(|section| {
|
|
let prior = previous.get(section.id()).map_or(
|
|
PreviousWorldStateSection::Absent,
|
|
PreviousWorldStateSection::Known,
|
|
);
|
|
(section.id().to_string(), section.render_diff(prior))
|
|
})
|
|
.collect::<std::collections::BTreeMap<_, _>>();
|
|
let cloud = &rendered["cloud_skills"];
|
|
let cloud_snapshot = cloud.0.as_ref().expect("cloud snapshot");
|
|
let cloud_body = cloud_snapshot["body"].as_str().ok_or("cloud catalog")?;
|
|
match phase {
|
|
Phase::Initial => initial_cloud = Some(cloud_body.to_string()),
|
|
Phase::FirstReady => {
|
|
assert_eq!(initial_cloud.as_deref() != Some(cloud_body), rebalance);
|
|
accepted_cloud = Some(cloud_snapshot.clone());
|
|
let executor = rendered["skills"].0.as_ref().expect("executor snapshot");
|
|
let executor_body = executor["body"].as_str().ok_or("executor catalog")?;
|
|
assert_eq!(
|
|
executor_body
|
|
.lines()
|
|
.filter(|line| line.starts_with("- executor-skill-"))
|
|
.count()
|
|
== executor_count,
|
|
all_fit
|
|
);
|
|
if all_fit {
|
|
assert_eq!(
|
|
cloud_body
|
|
.lines()
|
|
.filter(|line| line.starts_with("- cloud-skill-"))
|
|
.count(),
|
|
cloud_count
|
|
);
|
|
}
|
|
}
|
|
Phase::CloudCatalogGrew => {
|
|
let allocation = &cloud_snapshot["allocation"];
|
|
let previous_limit = previous["cloud_skills"]["allocation"]["cloudLimit"]
|
|
.as_u64()
|
|
.ok_or("previous cloud limit")?;
|
|
assert!(
|
|
allocation["cloudLimit"].as_u64().ok_or("cloud limit")?
|
|
> previous_limit
|
|
);
|
|
assert_ne!(
|
|
allocation["cloudCatalogFingerprint"],
|
|
previous["cloud_skills"]["allocation"]["cloudCatalogFingerprint"]
|
|
);
|
|
assert_eq!(
|
|
cloud_body
|
|
.lines()
|
|
.filter(|line| line.starts_with("- cloud-skill-"))
|
|
.count(),
|
|
15
|
|
);
|
|
let executor = rendered["skills"].0.as_ref().expect("executor snapshot");
|
|
assert_eq!(
|
|
executor["body"]
|
|
.as_str()
|
|
.ok_or("executor catalog")?
|
|
.lines()
|
|
.filter(|line| line.starts_with("- executor-skill-"))
|
|
.count(),
|
|
executor_count
|
|
);
|
|
accepted_cloud = Some(cloud_snapshot.clone());
|
|
}
|
|
Phase::Disconnected | Phase::Reconnected | Phase::Resumed => {
|
|
assert_eq!(Some(cloud_snapshot), accepted_cloud.as_ref());
|
|
assert!(cloud.1.is_none());
|
|
}
|
|
Phase::BudgetChanged => {
|
|
// A genuinely different total budget starts a new allocation.
|
|
assert_eq!(
|
|
cloud_snapshot["allocation"],
|
|
serde_json::json!({
|
|
"totalBudget": {"tokens": 4_000}, "cloudLimit": 3_000,
|
|
"cloudCatalogFingerprint": previous["cloud_skills"]["allocation"]["cloudCatalogFingerprint"],
|
|
})
|
|
);
|
|
}
|
|
}
|
|
previous = rendered
|
|
.into_iter()
|
|
.map(|(id, (snapshot, _))| (id, snapshot.expect("skills snapshot")))
|
|
.collect();
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn extreme_budget_pressure_removes_descriptions_before_omitting_entries() -> TestResult {
|
|
let executor_entries = (0..40)
|
|
.map(|index| {
|
|
let package_id = format!("executor/executor-skill-{index:03}");
|
|
let mut entry = test_entry(
|
|
SkillSourceKind::Executor,
|
|
"env-1",
|
|
&package_id,
|
|
&format!("skill://{package_id}/SKILL.md"),
|
|
);
|
|
entry.description = format!("executor-description-{index:03}");
|
|
entry
|
|
})
|
|
.collect();
|
|
let cloud_entries = (0..160)
|
|
.map(|index| {
|
|
let package_id = format!("cloud/cloud-skill-{index:03}");
|
|
let mut entry = test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"codex_apps",
|
|
&package_id,
|
|
&format!("skill://{package_id}/SKILL.md"),
|
|
);
|
|
entry.description = format!("cloud-description-{index:03}");
|
|
entry
|
|
})
|
|
.collect();
|
|
let providers = SkillProviders::new()
|
|
.with_executor_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: executor_entries,
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}))
|
|
.with_cloud_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: cloud_entries,
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}));
|
|
let (event_tx, event_rx) = std::sync::mpsc::channel();
|
|
let mut builder =
|
|
ExtensionRegistryBuilder::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let session_source = SessionSource::Cli;
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
start_registered_turn(®istry, &session_store, &thread_store, "turn-1").await;
|
|
|
|
let (executor, cloud) =
|
|
skill_world_state_fragments(®istry, &session_store, &thread_store, "turn-1").await?;
|
|
let executor = executor.body().clone().expect_text();
|
|
let cloud = cloud.body().clone().expect_text();
|
|
let included_executor_count = executor
|
|
.lines()
|
|
.filter(|line| line.starts_with("- executor-skill-"))
|
|
.count();
|
|
let included_cloud_count = cloud
|
|
.lines()
|
|
.filter(|line| line.starts_with("- cloud-skill-"))
|
|
.count();
|
|
assert!(included_executor_count > 0);
|
|
assert!(included_executor_count < 40);
|
|
assert!(included_cloud_count > 0);
|
|
assert!(included_cloud_count < 160);
|
|
assert!(executor.contains("- executor-skill-000: (executor package:"));
|
|
assert!(cloud.contains("- cloud-skill-000: (cloud package:"));
|
|
assert!(!cloud.contains("- cloud-skill-159:"));
|
|
for (total, included, rendered) in [
|
|
(40, included_executor_count, executor),
|
|
(160, included_cloud_count, cloud),
|
|
] {
|
|
assert!(!rendered.contains("description-"));
|
|
assert!(rendered.contains("additional skills omitted from this bounded skills list"));
|
|
let omitted_count = total - included;
|
|
let warning = event_rx.try_recv()?.into_warning();
|
|
assert_eq!(warning.thread_id, "thread");
|
|
assert_eq!(warning.turn_id.as_deref(), Some("turn-1"));
|
|
assert_eq!(
|
|
warning.message,
|
|
format!(
|
|
"Exceeded skills context budget. All skill descriptions were removed and {omitted_count} additional skills were not included in the model-visible skills list."
|
|
)
|
|
);
|
|
}
|
|
assert!(event_rx.try_recv().is_err());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn skills_list_only_returns_model_visible_bounded_metadata() -> TestResult {
|
|
const OVERSIZED_WARNING: &str = "Some skills were omitted because their metadata is too large.";
|
|
const PROVIDER_WARNING: &str = "Some cloud skills could not be discovered because the provider disconnected before returning the complete catalog.";
|
|
|
|
let supplementary_warning = "w".repeat(256);
|
|
let opaque_suffix = "\\".repeat(1_500);
|
|
let mut entry = test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"codex_apps",
|
|
&format!("cloud/{opaque_suffix}"),
|
|
&format!("skill://cloud/{opaque_suffix}/SKILL.md"),
|
|
);
|
|
entry.description = "x".repeat(1_025);
|
|
let [first_entry, mut middle_entry, mut final_entry] = ["p0", "p1", "p2"].map(|name| {
|
|
test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"codex_apps",
|
|
name,
|
|
&format!("skill://{name}/SKILL.md"),
|
|
)
|
|
});
|
|
middle_entry.description = "d".repeat(110);
|
|
final_entry.description = "d".repeat(50);
|
|
let providers = SkillProviders::new().with_cloud_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![
|
|
entry,
|
|
first_entry,
|
|
middle_entry,
|
|
final_entry,
|
|
test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"codex_apps",
|
|
"cloud/hidden",
|
|
"skill://cloud/hidden/SKILL.md",
|
|
)
|
|
.hidden_from_prompt(),
|
|
],
|
|
warnings: vec![
|
|
PROVIDER_WARNING.to_string(),
|
|
supplementary_warning.clone(),
|
|
supplementary_warning.clone(),
|
|
supplementary_warning.clone(),
|
|
],
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}));
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let session_source = SessionSource::Cli;
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
start_registered_turn(®istry, &session_store, &thread_store, "turn-1").await;
|
|
|
|
let step_store = ExtensionData::new("step");
|
|
prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &ExtensionData::new("turn-1"),
|
|
},
|
|
)
|
|
.await;
|
|
let tools =
|
|
registry.tool_contributors()[0].tools_for_step(&session_store, &thread_store, &step_store);
|
|
let list_tool = tools
|
|
.iter()
|
|
.find(|tool| tool.tool_name().name == "list")
|
|
.ok_or("skills.list tool should be registered")?;
|
|
let payload = ToolPayload::Function {
|
|
arguments: serde_json::json!({"authority": {"kind": "cloud"}}).to_string(),
|
|
};
|
|
let call = ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_name: list_tool.tool_name(),
|
|
model: "gpt-test".to_string(),
|
|
codex_turn_metadata: None,
|
|
truncation_policy: TruncationPolicy::Bytes(10_000),
|
|
source: ToolCallSource::Direct,
|
|
conversation_history: ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
attachment_store: Arc::new(InlineAttachmentStore),
|
|
ephemeral: false,
|
|
environments: Vec::new(),
|
|
payload: payload.clone(),
|
|
};
|
|
let output = list_tool.handle(call.clone()).await?;
|
|
let complete_response = output
|
|
.post_tool_use_response("call-1", &payload)
|
|
.ok_or("skills.list should expose structured output")?;
|
|
let complete_skills = complete_response["skills"]
|
|
.as_array()
|
|
.ok_or("skills.list should return skills")?;
|
|
assert_eq!(complete_skills.len(), 4);
|
|
assert_eq!(complete_skills[0]["description"], "x".repeat(1_021) + "...");
|
|
assert_eq!(
|
|
complete_response["warnings"].as_array().map(Vec::len),
|
|
Some(4)
|
|
);
|
|
assert_eq!(complete_response["next_cursor"], serde_json::Value::Null);
|
|
|
|
let mut cursor = None;
|
|
for (
|
|
index,
|
|
(truncation_limit, byte_budget, expected_indices, expected_warnings, cursor_offset),
|
|
) in [
|
|
// The escaped entry fits alone, but not alongside even one provider warning.
|
|
(6_458, 7_750, vec![], vec![PROVIDER_WARNING], Some("0")),
|
|
(6_458, 7_750, vec![0], vec![], Some("1")),
|
|
(266, 320, vec![1], vec![], Some("2")),
|
|
// A smaller budget omits the middle entry and must report the new omission.
|
|
(183, 220, vec![], vec![OVERSIZED_WARNING], Some("2")),
|
|
(183, 220, vec![3], vec![], None),
|
|
// Neither report may hide the other when they need separate pages.
|
|
(183, 220, vec![], vec![PROVIDER_WARNING], Some("0")),
|
|
(183, 220, vec![], vec![OVERSIZED_WARNING], Some("0")),
|
|
// The last retained entry fits without a cursor, despite an oversized suffix.
|
|
(151, 182, vec![], vec![OVERSIZED_WARNING], Some("0")),
|
|
(151, 182, vec![1], vec![], None),
|
|
// An omission notice must not displace an affordable provider warning.
|
|
(
|
|
2_000,
|
|
2_400,
|
|
vec![1, 2, 3],
|
|
vec![
|
|
PROVIDER_WARNING,
|
|
&supplementary_warning,
|
|
&supplementary_warning,
|
|
&supplementary_warning,
|
|
OVERSIZED_WARNING,
|
|
],
|
|
None,
|
|
),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let payload = ToolPayload::Function {
|
|
arguments: serde_json::json!({
|
|
"authority": {"kind": "cloud"},
|
|
"cursor": cursor,
|
|
})
|
|
.to_string(),
|
|
};
|
|
let call_id = format!("bounded-page-{index}");
|
|
let output = list_tool
|
|
.handle(ToolCall {
|
|
call_id: call_id.clone(),
|
|
truncation_policy: TruncationPolicy::Bytes(truncation_limit),
|
|
payload: payload.clone(),
|
|
..call.clone()
|
|
})
|
|
.await?;
|
|
assert!(output.contains_external_context());
|
|
let response = output
|
|
.post_tool_use_response(&call_id, &payload)
|
|
.ok_or("skills.list should expose structured output")?;
|
|
assert!(serde_json::to_vec(&response)?.len() <= byte_budget);
|
|
let next_cursor = response["next_cursor"].as_str().map(str::to_owned);
|
|
assert_eq!(
|
|
next_cursor
|
|
.as_deref()
|
|
.and_then(|value| value.split(':').nth(1)),
|
|
cursor_offset
|
|
);
|
|
let expected_skills = expected_indices
|
|
.into_iter()
|
|
.map(|index| &complete_skills[index])
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
response,
|
|
serde_json::json!({
|
|
"skills": expected_skills,
|
|
"warnings": expected_warnings,
|
|
"next_cursor": next_cursor,
|
|
})
|
|
);
|
|
if index == 2 {
|
|
let (legacy_cursor, _) = next_cursor
|
|
.as_deref()
|
|
.and_then(|cursor| cursor.rsplit_once(':'))
|
|
.ok_or("cursor should include its budget")?;
|
|
let legacy_payload = ToolPayload::Function {
|
|
arguments: serde_json::json!({
|
|
"authority": {"kind": "cloud"},
|
|
"cursor": legacy_cursor,
|
|
})
|
|
.to_string(),
|
|
};
|
|
let output = list_tool
|
|
.handle(ToolCall {
|
|
payload: legacy_payload.clone(),
|
|
..call.clone()
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
output.post_tool_use_response(&call.call_id, &legacy_payload),
|
|
Some(serde_json::json!({
|
|
"skills": &complete_skills[2..],
|
|
"warnings": [],
|
|
"next_cursor": null,
|
|
}))
|
|
);
|
|
}
|
|
cursor = next_cursor;
|
|
}
|
|
|
|
assert_eq!(
|
|
list_tool
|
|
.handle(ToolCall {
|
|
call_id: "omitted-call".to_string(),
|
|
truncation_policy: TruncationPolicy::Bytes(64),
|
|
..call
|
|
})
|
|
.await
|
|
.err(),
|
|
Some(FunctionCallError::RespondToModel(
|
|
"skills.list response budget leaves no room for discovery warnings".to_string()
|
|
))
|
|
);
|
|
|
|
let read_tool = tools
|
|
.iter()
|
|
.find(|tool| tool.tool_name().name == "read")
|
|
.ok_or("skills.read tool should be registered")?;
|
|
// The resource fits the 2400-byte budget before escaping, but not after.
|
|
let insufficient_budget_payload = ToolPayload::Function {
|
|
arguments: serde_json::json!({
|
|
"package": format!("cloud/{opaque_suffix}"),
|
|
})
|
|
.to_string(),
|
|
};
|
|
let error = read_tool
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "insufficient-read-budget".to_string(),
|
|
tool_name: read_tool.tool_name(),
|
|
model: "gpt-test".to_string(),
|
|
codex_turn_metadata: None,
|
|
truncation_policy: TruncationPolicy::Bytes(2_000),
|
|
source: ToolCallSource::Direct,
|
|
conversation_history: ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
attachment_store: Arc::new(InlineAttachmentStore),
|
|
ephemeral: false,
|
|
environments: Vec::new(),
|
|
payload: insufficient_budget_payload,
|
|
})
|
|
.await
|
|
.err()
|
|
.expect("skills.read should reject a response that cannot fit any contents");
|
|
let error = assert_matches!(error, FunctionCallError::RespondToModel(error) => error);
|
|
assert_eq!(
|
|
error,
|
|
"skills.read response budget leaves no room for contents"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cloud_discovery_is_gated_and_reports_failures() -> TestResult {
|
|
for (cloud_skill_enabled, has_cloud_provider) in [(false, true), (true, true), (true, false)] {
|
|
let list_calls = Arc::new(AtomicUsize::new(0));
|
|
let mut providers = SkillProviders::new();
|
|
if has_cloud_provider {
|
|
providers = providers.with_cloud_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog::default(),
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: Some(Arc::clone(&list_calls)),
|
|
fail_first_list: true,
|
|
}));
|
|
}
|
|
let cloud_available = cloud_skill_enabled && has_cloud_provider;
|
|
let (event_tx, event_rx) = std::sync::mpsc::channel();
|
|
let mut builder =
|
|
ExtensionRegistryBuilder::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let session_source = SessionSource::Cli;
|
|
let config = TestConfig {
|
|
cloud_skill_enabled,
|
|
..default_config()
|
|
};
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(
|
|
registry
|
|
.turn_lifecycle_contributors()
|
|
.iter()
|
|
.map(|contributor| contributor.turn_start_phase(&thread_store))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
codex_extension_api::TurnStartPhase::BeforeTaskRegistration,
|
|
if cloud_available {
|
|
codex_extension_api::TurnStartPhase::RegularTaskStart
|
|
} else {
|
|
codex_extension_api::TurnStartPhase::BeforeTaskRegistration
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
registry
|
|
.turn_lifecycle_contributors()
|
|
.iter()
|
|
.map(|contributor| contributor.requires_mcp_runtime(&thread_store))
|
|
.collect::<Vec<_>>(),
|
|
vec![false, cloud_available]
|
|
);
|
|
start_registered_turn(®istry, &session_store, &thread_store, "turn-1").await;
|
|
assert_eq!(
|
|
list_calls.load(Ordering::Relaxed),
|
|
usize::from(cloud_available)
|
|
);
|
|
for discovery_v2 in [None, Some([].as_slice())] {
|
|
let step_store = ExtensionData::new("step");
|
|
if let Some(snapshots) = discovery_v2 {
|
|
step_store.insert(Vec::<
|
|
codex_exec_server::ExecutorCapabilityDiscoveryV2Snapshot,
|
|
>::from(snapshots));
|
|
}
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &ExtensionData::new("turn-1"),
|
|
},
|
|
)
|
|
.await;
|
|
if has_cloud_provider {
|
|
// Resume may retain a catalog even when the new Cloud discovery fails.
|
|
let previous = serde_json::json!({
|
|
"body": "Previously advertised Cloud skills",
|
|
"includeInstructions": true,
|
|
"enabled": true,
|
|
});
|
|
let update = world_state_section(§ions, "cloud_skills")
|
|
.render_diff(PreviousWorldStateSection::Known(&previous))
|
|
.1
|
|
.ok_or("unavailable Cloud skills should withdraw the previous catalog")?;
|
|
assert_eq!(
|
|
update.body().clone().expect_text(),
|
|
"\n## Cloud skills update\nNo cloud skills are currently available.\n"
|
|
);
|
|
} else {
|
|
assert!(
|
|
sections
|
|
.iter()
|
|
.all(|section| section.id() != "cloud_skills")
|
|
);
|
|
}
|
|
}
|
|
if !cloud_available {
|
|
assert!(event_rx.try_recv().is_err());
|
|
let tools = registry.tool_contributors()[0].tools_for_step(
|
|
&session_store,
|
|
&thread_store,
|
|
&ExtensionData::new("step"),
|
|
);
|
|
assert!(tools.iter().all(|tool| tool.tool_name().name != "list"));
|
|
continue;
|
|
}
|
|
let warning = event_rx.try_recv()?.into_warning();
|
|
assert_eq!(warning.thread_id, thread_store.level_id());
|
|
assert_eq!(warning.turn_id.as_deref(), Some("turn-1"));
|
|
assert!(warning.message.contains("temporary cloud failure"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn v2_root_aliases_and_read_cache_use_the_captured_environment() -> TestResult {
|
|
let directory = tempfile::tempdir()?;
|
|
let root = directory.path().join("skills");
|
|
let path = root.join("nested/demo/SKILL.md");
|
|
std::fs::create_dir_all(path.parent().ok_or("skill directory")?)?;
|
|
std::fs::write(&path, "original\n".repeat(2_000))?;
|
|
let manager = Arc::new(EnvironmentManager::default_for_tests());
|
|
let environment = manager
|
|
.get_environment("local")
|
|
.ok_or("local environment")?;
|
|
let replacement = EnvironmentManager::default_for_tests()
|
|
.get_environment("local")
|
|
.ok_or("replacement environment")?;
|
|
let providers = SkillProviders::new().with_executor_provider(Arc::new(
|
|
ExecutorSkillProvider::new_with_restriction_product(
|
|
manager, /*restriction_product*/ None,
|
|
),
|
|
));
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &default_config(),
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
let snapshots = vec![ExecutorCapabilityDiscoveryV2Snapshot {
|
|
environment_id: "captured".to_string(),
|
|
result: Ok(Arc::new(DiscoverV2CapabilitiesResponse {
|
|
skills: ["demo", "build", "check", "deploy", "review", "test"]
|
|
.into_iter()
|
|
.map(|name| {
|
|
Ok(ExecutorSkill {
|
|
name: name.to_string(),
|
|
description: "Demo".to_string(),
|
|
short_description: None,
|
|
path: PathUri::from_host_native_path(
|
|
root.join(format!("nested/{name}/SKILL.md")),
|
|
)?,
|
|
discovery_root: Some(SkillDiscoveryRoot {
|
|
id: "codex-home-skills".to_string(),
|
|
path: PathUri::from_host_native_path(&root)?,
|
|
}),
|
|
dependencies: None,
|
|
policy: None,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?,
|
|
..Default::default()
|
|
})),
|
|
}];
|
|
let package = format!(
|
|
"skill://codex-home-skills/{}",
|
|
PathUri::from_host_native_path(path.parent().ok_or("skill directory")?)?
|
|
.inferred_native_path_string()
|
|
.replace('\\', "/")
|
|
.trim_start_matches('/')
|
|
);
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
let mut cursor = None;
|
|
for (handle, expected_error) in [
|
|
(Some(Arc::clone(&environment)), None),
|
|
(Some(environment), None),
|
|
(
|
|
Some(replacement),
|
|
Some("skills.read cursor is stale; restart from the first page"),
|
|
),
|
|
(
|
|
None,
|
|
Some("skill environment is not available for this callback"),
|
|
),
|
|
] {
|
|
let file_system = handle.as_ref().map(|handle| handle.get_filesystem());
|
|
let access = file_system
|
|
.as_ref()
|
|
.map(FileSystemEnvironmentAccessor::unrestricted);
|
|
let step_store = ExtensionData::new("turn-1");
|
|
step_store.insert(snapshots.clone());
|
|
step_store.insert(
|
|
handle
|
|
.map(|handle| ("captured".to_string(), handle))
|
|
.into_iter()
|
|
.collect::<HashMap<_, _>>(),
|
|
);
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&[],
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let rendered = sections
|
|
.iter()
|
|
.filter_map(|section| section.render_diff(PreviousWorldStateSection::Absent).1)
|
|
.map(|fragment| fragment.body().clone().expect_text())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(rendered.contains("e0/nested/demo"), "{rendered}");
|
|
let tools = registry.tool_contributors()[0].tools_for_step(
|
|
&session_store,
|
|
&thread_store,
|
|
&step_store,
|
|
);
|
|
let read = tools
|
|
.iter()
|
|
.find(|tool| tool.tool_name().name == "read")
|
|
.ok_or("skills.read")?;
|
|
let payload = ToolPayload::Function {
|
|
arguments: serde_json::json!({"package": "e0/nested/demo", "cursor": cursor})
|
|
.to_string(),
|
|
};
|
|
let result = read
|
|
.handle(ToolCall {
|
|
turn_id: "turn-1".to_string(),
|
|
call_id: "read".to_string(),
|
|
tool_name: read.tool_name(),
|
|
model: "gpt-test".to_string(),
|
|
codex_turn_metadata: None,
|
|
truncation_policy: TruncationPolicy::Bytes(2_000),
|
|
source: ToolCallSource::Direct,
|
|
conversation_history: ConversationHistory::default(),
|
|
turn_item_emitter: Arc::new(NoopTurnItemEmitter),
|
|
attachment_store: Arc::new(InlineAttachmentStore),
|
|
ephemeral: false,
|
|
environments: access
|
|
.iter()
|
|
.map(|access| {
|
|
codex_extension_api::ToolEnvironment::new(
|
|
"captured".to_string(),
|
|
PathUri::from_host_native_path(directory.path()).expect("fixture cwd"),
|
|
access,
|
|
)
|
|
})
|
|
.collect(),
|
|
payload: payload.clone(),
|
|
})
|
|
.await;
|
|
if let Some(expected) = expected_error {
|
|
assert!(
|
|
matches!(result, Err(FunctionCallError::RespondToModel(message)) if message == expected)
|
|
);
|
|
} else {
|
|
let response = result?
|
|
.post_tool_use_response("read", &payload)
|
|
.ok_or("read output")?;
|
|
assert_eq!(response["resource"], format!("{package}/SKILL.md"));
|
|
assert_eq!(
|
|
response["installations"],
|
|
serde_json::json!([{
|
|
"environment_id": "captured",
|
|
"skill_root": PathUri::from_host_native_path(path.parent().ok_or("skill directory")?)?
|
|
.inferred_native_path_string(),
|
|
}])
|
|
);
|
|
assert!(
|
|
response["contents"]
|
|
.as_str()
|
|
.ok_or("contents")?
|
|
.contains("original")
|
|
);
|
|
cursor = Some(
|
|
response["next_cursor"]
|
|
.as_str()
|
|
.ok_or("next cursor")?
|
|
.to_string(),
|
|
);
|
|
// The next page keeps the cached contents only while the captured handle is unchanged.
|
|
std::fs::write(&path, "updated\n".repeat(2_000))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> TestResult {
|
|
let step_store = ExtensionData::new("step-1");
|
|
let read_requests = Arc::new(Mutex::new(Vec::new()));
|
|
let root_a_locator = "skill://root-a/shared/lint-fix/SKILL.md";
|
|
let root_b_locator = "skill://root-b/shared/lint-fix/SKILL.md";
|
|
let skill_path = PathUri::parse("file:///shared/lint-fix/SKILL.md")?;
|
|
let executor_provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: [("root-a", root_a_locator), ("root-b", root_b_locator)]
|
|
.into_iter()
|
|
.map(|(root_id, locator)| {
|
|
SkillCatalogEntry::new(
|
|
SkillPackageId(locator.to_string()),
|
|
SkillAuthority::new(SkillSourceKind::Executor, root_id),
|
|
"lint-fix",
|
|
"Fix lint errors.",
|
|
SkillResourceId::environment(locator, "env-1", skill_path.clone()),
|
|
)
|
|
.with_display_path(locator)
|
|
})
|
|
.collect(),
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::clone(&read_requests),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
});
|
|
let providers = SkillProviders::new().with_executor_provider(executor_provider);
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let selected_roots = [("root-a", "/skills/root-a"), ("root-b", "/skills/root-b")]
|
|
.into_iter()
|
|
.map(|(id, path)| SelectedCapabilityRoot {
|
|
id: id.to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse(&format!("file://{path}")).expect("skill root URI"),
|
|
},
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let session_source = SessionSource::Cli;
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[TurnEnvironmentSelection {
|
|
selected_capability_roots: Default::default(),
|
|
environment_id: "env-1".to_string(),
|
|
cwd: PathUri::parse("file:///workspace").expect("cwd URI"),
|
|
workspace_roots: Vec::new(),
|
|
config: EnvironmentConfigState::FromThread,
|
|
}],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
let fragments = prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &step_store,
|
|
turn_id: "turn-1".to_string(),
|
|
user_input: vec![UserInput::Mention {
|
|
name: "lint-fix".to_string(),
|
|
path: root_b_locator.to_string(),
|
|
}],
|
|
environments: vec![codex_extension_api::TurnInputEnvironment {
|
|
environment_id: "env-1".to_string(),
|
|
cwd: PathUri::parse("file:///workspace")?,
|
|
is_primary: true,
|
|
fs: &FileSystemEnvironmentAccessor::unrestricted(&LOCAL_FS),
|
|
}],
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&turn_store,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(1, fragments.len());
|
|
assert!(fragments[0].render().expect_text().contains(root_b_locator));
|
|
assert_eq!(
|
|
vec![(
|
|
SkillAuthority::new(SkillSourceKind::Executor, "root-b"),
|
|
SkillPackageId(root_b_locator.to_string()),
|
|
SkillResourceId::environment(root_b_locator, "env-1", skill_path),
|
|
)],
|
|
read_request_keys(&read_requests)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_context_window_scales_executor_and_cloud_catalogs() -> TestResult {
|
|
let cloud_entries = (0..40)
|
|
.map(|index| {
|
|
test_entry(
|
|
SkillSourceKind::Cloud,
|
|
"cloud",
|
|
&format!("cloud/skill-{index:02}"),
|
|
&format!("skill-{index:02}/SKILL.md"),
|
|
)
|
|
})
|
|
.collect();
|
|
let executor_entries = (0..200)
|
|
.map(|index| {
|
|
test_entry(
|
|
SkillSourceKind::Executor,
|
|
"env-1",
|
|
&format!("executor/skill-{index:02}"),
|
|
&format!("skill-{index:02}/SKILL.md"),
|
|
)
|
|
})
|
|
.collect();
|
|
let providers = SkillProviders::new()
|
|
.with_cloud_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: cloud_entries,
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}))
|
|
.with_executor_provider(Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: executor_entries,
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
}));
|
|
let (event_tx, event_rx) = std::sync::mpsc::channel();
|
|
let mut builder =
|
|
ExtensionRegistryBuilder::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let mut config = default_config();
|
|
config.bundled_skills_enabled = false;
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
start_registered_turn(®istry, &session_store, &thread_store, "turn-1").await;
|
|
let mut model_info = model_info_from_slug("test-model");
|
|
model_info.context_window = Some(10_000);
|
|
|
|
let thread_fragments = registry.context_contributors()[0]
|
|
.contribute_thread_context(&session_store, &thread_store)
|
|
.await;
|
|
assert!(thread_fragments.is_empty());
|
|
|
|
let selected_roots = vec![SelectedCapabilityRoot {
|
|
id: "skills".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills").expect("skill root URI"),
|
|
},
|
|
}];
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
// Core rebuilds world state before each sampling step.
|
|
let _repeated_sections = prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &ExtensionData::new("step"),
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
assert!(event_rx.try_recv().is_err());
|
|
let executor_section = world_state_section(§ions, "skills");
|
|
let (snapshot, executor_fragment) =
|
|
executor_section.render_diff(PreviousWorldStateSection::Absent);
|
|
let snapshot = snapshot.unwrap();
|
|
let executor_fragment = executor_fragment.ok_or("bounded executor catalog should render")?;
|
|
assert!(
|
|
!executor_fragment
|
|
.body()
|
|
.clone()
|
|
.expect_text()
|
|
.contains("skill-39")
|
|
);
|
|
let cloud_fragment = world_state_section(§ions, "cloud_skills")
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1
|
|
.ok_or("bounded cloud catalog should render")?;
|
|
assert!(
|
|
cloud_fragment
|
|
.body()
|
|
.clone()
|
|
.expect_text()
|
|
.contains("additional skills omitted")
|
|
);
|
|
let warnings = event_rx
|
|
.try_iter()
|
|
.map(CapturedExtensionEvent::into_warning)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(warnings.len(), 2);
|
|
for warning in warnings {
|
|
assert_eq!(warning.thread_id, thread_store.level_id());
|
|
assert_eq!(warning.turn_id.as_deref(), Some("turn-1"));
|
|
assert!(
|
|
warning
|
|
.message
|
|
.starts_with("Exceeded skills context budget.")
|
|
);
|
|
assert!(
|
|
warning
|
|
.message
|
|
.ends_with("additional skills were not included in the model-visible skills list.")
|
|
);
|
|
}
|
|
assert!(
|
|
executor_section
|
|
.render_diff(PreviousWorldStateSection::Known(&snapshot))
|
|
.1
|
|
.is_none()
|
|
);
|
|
assert!(event_rx.try_recv().is_err());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn executor_catalog_emits_at_most_four_warnings() -> TestResult {
|
|
let step_store = ExtensionData::new("step-1");
|
|
let executor_provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: Vec::new(),
|
|
warnings: (0..6).map(|index| format!("warning-{index}")).collect(),
|
|
},
|
|
read_requests: Arc::new(Mutex::new(Vec::new())),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
});
|
|
let providers = SkillProviders::new().with_executor_provider(executor_provider);
|
|
let (event_tx, event_rx) = std::sync::mpsc::channel();
|
|
let mut builder =
|
|
ExtensionRegistryBuilder::with_event_sink(Arc::new(ChannelEventSink(event_tx)));
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let mut config = default_config();
|
|
config.bundled_skills_enabled = false;
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
let selected_roots = vec![SelectedCapabilityRoot {
|
|
id: "skills".to_string(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "env-1".to_string(),
|
|
path: PathUri::parse("file:///skills").expect("skill root URI"),
|
|
},
|
|
}];
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
|
|
prepared_world_state(
|
|
®istry,
|
|
&selected_roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
step_store: &step_store,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
},
|
|
)
|
|
.await;
|
|
prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &step_store,
|
|
turn_id: "turn-1".to_string(),
|
|
user_input: Vec::new(),
|
|
environments: Vec::new(),
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&turn_store,
|
|
)
|
|
.await;
|
|
|
|
let messages = event_rx
|
|
.try_iter()
|
|
.map(|event| event.into_warning().message)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
messages,
|
|
vec!["warning-0", "warning-1", "warning-2", "warning-3"]
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn host_catalog_compacts_shared_paths_under_budget_pressure() -> TestResult {
|
|
let test_root = test_codex_home();
|
|
let root = test_root.join(
|
|
"plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix",
|
|
);
|
|
for index in 0..12 {
|
|
let skill_dir = root.join(format!("skill-{index:02}"));
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
format!(
|
|
"---\nname: skill-{index:02}\ndescription: Fix lint errors.\n---\n# Skill {index:02}\n"
|
|
),
|
|
)?;
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
let source_skill_dir = test_root.join("shared-skills/linked-skill");
|
|
std::fs::create_dir_all(&source_skill_dir)?;
|
|
std::fs::write(
|
|
source_skill_dir.join("SKILL.md"),
|
|
"---\nname: linked-skill\ndescription: Linked skill.\n---\n# Linked skill\n",
|
|
)?;
|
|
std::os::unix::fs::symlink(&source_skill_dir, root.join("linked-skill"))?;
|
|
}
|
|
let root = AbsolutePathBuf::try_from(std::fs::canonicalize(root)?)?;
|
|
let rendered_root = root.to_string_lossy().replace('\\', "/");
|
|
let config_layer_stack = ConfigLayerStack::new(
|
|
vec![ConfigLayerEntry::new(
|
|
ConfigLayerSource::SessionFlags,
|
|
toml::from_str("[skills.bundled]\nenabled = false\n")?,
|
|
)],
|
|
Default::default(),
|
|
ConfigRequirementsToml::default(),
|
|
)?;
|
|
let codex_home = AbsolutePathBuf::try_from(test_root.clone())?;
|
|
let service = HostSkillsService::new_with_restriction_product(
|
|
codex_home.clone(),
|
|
/*bundled_skills_enabled*/ false,
|
|
/*restriction_product*/ None,
|
|
);
|
|
service.set_extra_roots(vec![root]);
|
|
let snapshot = service
|
|
.snapshot_for_config(
|
|
&HostSkillsLoadInput::new(codex_home, Vec::new(), config_layer_stack),
|
|
Some(Arc::clone(&LOCAL_FS)),
|
|
)
|
|
.await;
|
|
assert_eq!(snapshot.outcome().errors, Vec::new());
|
|
assert_eq!(
|
|
snapshot.outcome().skills.len(),
|
|
12 + usize::from(cfg!(unix))
|
|
);
|
|
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install(&mut builder, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let mut config = default_config();
|
|
config.bundled_skills_enabled = false;
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
let mut model_info = model_info_from_slug("test-model");
|
|
model_info.context_window = Some(10_000);
|
|
thread_store.insert(model_info);
|
|
let turn_store = ExtensionData::new("turn-1");
|
|
turn_store.insert(snapshot);
|
|
|
|
let fragments = prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &ExtensionData::new("step"),
|
|
turn_id: "turn-1".to_string(),
|
|
user_input: vec![UserInput::Text {
|
|
text: "hello".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
environments: Vec::new(),
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&turn_store,
|
|
)
|
|
.await;
|
|
let catalog = fragments
|
|
.iter()
|
|
.find(|fragment| fragment.role() == "developer")
|
|
.ok_or("host catalog should render")?
|
|
.render()
|
|
.expect_text();
|
|
|
|
assert!(
|
|
catalog.contains(&format!("- `r0` = `{rendered_root}`")),
|
|
"{catalog}"
|
|
);
|
|
assert!(catalog.contains("(file: r0/skill-00/SKILL.md)"));
|
|
assert!(catalog.contains("(file: r0/skill-11/SKILL.md)"));
|
|
#[cfg(unix)]
|
|
assert!(catalog.contains("(file: r0/linked-skill/SKILL.md)"));
|
|
assert!(!catalog.contains("additional skills omitted"));
|
|
|
|
std::fs::remove_dir_all(test_root)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult {
|
|
let read_requests = Arc::new(Mutex::new(Vec::new()));
|
|
let provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
is_partial: false,
|
|
entries: vec![
|
|
test_entry(
|
|
SkillSourceKind::Host,
|
|
"host",
|
|
"host/visible-skill",
|
|
"visible-skill/SKILL.md",
|
|
),
|
|
test_entry(
|
|
SkillSourceKind::Host,
|
|
"host",
|
|
"host/hidden-skill",
|
|
"hidden-skill/SKILL.md",
|
|
)
|
|
.hidden_from_prompt(),
|
|
],
|
|
warnings: Vec::new(),
|
|
},
|
|
read_requests: Arc::clone(&read_requests),
|
|
list_calls: None,
|
|
fail_first_list: false,
|
|
});
|
|
let providers = SkillProviders::new().with_host_provider(provider);
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(&mut builder, providers, skills_extension_config);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let session_source = SessionSource::Cli;
|
|
let config = default_config();
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &config,
|
|
session_source: &session_source,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
|
|
let fragments = prepared_turn_input(
|
|
®istry,
|
|
TurnInputContext {
|
|
step_store: &ExtensionData::new("step"),
|
|
turn_id: "turn-1".to_string(),
|
|
user_input: vec![UserInput::Text {
|
|
text: "$hidden-skill".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
environments: Vec::new(),
|
|
},
|
|
/*extension_metrics*/ None,
|
|
&session_store,
|
|
&thread_store,
|
|
&ExtensionData::new("turn-1"),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(2, fragments.len());
|
|
let visible = fragments[0].render().expect_text();
|
|
let hidden = fragments[1].render().expect_text();
|
|
assert!(visible.contains("visible-skill"));
|
|
assert!(!visible.contains("hidden-skill"));
|
|
assert!(hidden.contains("<name>hidden-skill</name>"));
|
|
assert_eq!(
|
|
vec![(
|
|
SkillAuthority::new(SkillSourceKind::Host, "host"),
|
|
SkillPackageId("host/hidden-skill".to_string()),
|
|
SkillResourceId::new("hidden-skill/SKILL.md"),
|
|
)],
|
|
read_request_keys(&read_requests)
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct StaticSkillProvider {
|
|
catalog: SkillCatalog,
|
|
read_requests: Arc<Mutex<Vec<(SkillAuthority, SkillPackageId, SkillResourceId)>>>,
|
|
list_calls: Option<Arc<AtomicUsize>>,
|
|
fail_first_list: bool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum CapturedExtensionEvent {
|
|
Event(Box<Event>),
|
|
Warning(ExtensionWarning),
|
|
}
|
|
|
|
impl CapturedExtensionEvent {
|
|
fn into_warning(self) -> ExtensionWarning {
|
|
match self {
|
|
Self::Warning(warning) => warning,
|
|
Self::Event(event) => panic!("expected extension warning, got {event:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ChannelEventSink(std::sync::mpsc::Sender<CapturedExtensionEvent>);
|
|
|
|
impl ExtensionEventSink for ChannelEventSink {
|
|
fn emit(&self, event: Event) {
|
|
let _ = self.0.send(CapturedExtensionEvent::Event(Box::new(event)));
|
|
}
|
|
|
|
fn emit_warning(&self, warning: ExtensionWarning) {
|
|
let _ = self.0.send(CapturedExtensionEvent::Warning(warning));
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct RecordedHistogram {
|
|
name: String,
|
|
value: i64,
|
|
tags: Vec<(String, String)>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RecordingMetrics {
|
|
samples: Mutex<Vec<RecordedHistogram>>,
|
|
}
|
|
|
|
impl RecordingMetrics {
|
|
fn samples(&self) -> Vec<RecordedHistogram> {
|
|
self.samples
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone()
|
|
}
|
|
}
|
|
|
|
impl ExtensionMetrics for RecordingMetrics {
|
|
fn histogram_with_boundaries(
|
|
&self,
|
|
name: &str,
|
|
value: i64,
|
|
_boundaries: &[f64],
|
|
tags: &[(&str, &str)],
|
|
) {
|
|
self.histogram(name, value, tags);
|
|
}
|
|
|
|
fn counter(&self, name: &str, _inc: i64, _tags: &[(&str, &str)]) {
|
|
panic!("unexpected counter: {name}");
|
|
}
|
|
|
|
fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
|
|
self.samples
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push(RecordedHistogram {
|
|
name: name.to_string(),
|
|
value,
|
|
tags: tags
|
|
.iter()
|
|
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
|
|
.collect(),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn expected_catalog_metric_samples(catalog_surface: &str, count: i64) -> Vec<RecordedHistogram> {
|
|
let tags = vec![("catalog_surface".to_string(), catalog_surface.to_string())];
|
|
vec![
|
|
RecordedHistogram {
|
|
name: THREAD_SKILLS_ENABLED_TOTAL_METRIC.to_string(),
|
|
value: count,
|
|
tags: tags.clone(),
|
|
},
|
|
RecordedHistogram {
|
|
name: THREAD_SKILLS_KEPT_TOTAL_METRIC.to_string(),
|
|
value: count,
|
|
tags: tags.clone(),
|
|
},
|
|
RecordedHistogram {
|
|
name: THREAD_SKILLS_TRUNCATED_METRIC.to_string(),
|
|
value: 0,
|
|
tags: tags.clone(),
|
|
},
|
|
RecordedHistogram {
|
|
name: THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC.to_string(),
|
|
value: 0,
|
|
tags,
|
|
},
|
|
]
|
|
}
|
|
|
|
impl SkillProvider for StaticSkillProvider {
|
|
fn list(&self, _query: SkillListQuery) -> SkillProviderFuture<'_, SkillCatalog> {
|
|
let list_call = self
|
|
.list_calls
|
|
.as_ref()
|
|
.map(|list_calls| list_calls.fetch_add(1, Ordering::Relaxed));
|
|
let fail = self.fail_first_list && list_call == Some(0);
|
|
let catalog = self.catalog.clone();
|
|
Box::pin(async move {
|
|
if fail {
|
|
Err(SkillProviderError::new("temporary cloud failure"))
|
|
} else {
|
|
Ok(catalog)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn read<'a>(
|
|
&'a self,
|
|
request: SkillReadRequest<'a>,
|
|
) -> SkillProviderFuture<'a, SkillReadResult> {
|
|
let read_requests = Arc::clone(&self.read_requests);
|
|
Box::pin(async move {
|
|
read_requests
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push((
|
|
request.authority.clone(),
|
|
request.package.clone(),
|
|
request.resource.clone(),
|
|
));
|
|
Ok(SkillReadResult {
|
|
resource: request.resource,
|
|
contents: "# Lint Fix\n\nRun the formatter.".to_string(),
|
|
})
|
|
})
|
|
}
|
|
|
|
fn search(&self, _request: SkillSearchRequest) -> SkillProviderFuture<'_, SkillSearchResult> {
|
|
Box::pin(async { Ok(SkillSearchResult::default()) })
|
|
}
|
|
}
|
|
|
|
fn test_entry(
|
|
kind: SkillSourceKind,
|
|
authority_id: &str,
|
|
package_id: &str,
|
|
main_prompt: &str,
|
|
) -> SkillCatalogEntry {
|
|
let name = package_id.rsplit('/').next().unwrap_or(package_id);
|
|
SkillCatalogEntry::new(
|
|
SkillPackageId(package_id.to_string()),
|
|
SkillAuthority::new(kind, authority_id),
|
|
name,
|
|
"Fix lint errors.",
|
|
SkillResourceId::new(main_prompt),
|
|
)
|
|
.with_display_path(format!("skill://{package_id}/SKILL.md"))
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
struct TestConfig {
|
|
include_instructions: bool,
|
|
bundled_skills_enabled: bool,
|
|
cloud_skill_enabled: bool,
|
|
shadow_selection_enabled: bool,
|
|
}
|
|
|
|
fn default_config() -> TestConfig {
|
|
TestConfig {
|
|
include_instructions: true,
|
|
bundled_skills_enabled: true,
|
|
cloud_skill_enabled: true,
|
|
shadow_selection_enabled: false,
|
|
}
|
|
}
|
|
|
|
fn skills_extension_config(config: &TestConfig) -> SkillsExtensionConfig {
|
|
SkillsExtensionConfig {
|
|
include_instructions: config.include_instructions,
|
|
max_context_tokens: None,
|
|
bundled_skills_enabled: config.bundled_skills_enabled,
|
|
cloud_skill_enabled: config.cloud_skill_enabled,
|
|
shadow_selection_enabled: config.shadow_selection_enabled,
|
|
}
|
|
}
|
|
|
|
fn test_codex_home() -> PathBuf {
|
|
let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed);
|
|
std::env::temp_dir().join(format!(
|
|
"codex-skills-extension-test-{}-{id}",
|
|
std::process::id(),
|
|
))
|
|
}
|
|
|
|
fn read_request_keys(
|
|
requests: &Mutex<Vec<(SkillAuthority, SkillPackageId, SkillResourceId)>>,
|
|
) -> Vec<(SkillAuthority, SkillPackageId, SkillResourceId)> {
|
|
requests
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone()
|
|
}
|
|
|
|
#[path = "skills_extension/merge_catalog_tests.rs"]
|
|
mod merge_catalog_tests;
|
|
|
|
#[path = "skills_extension/discovery_v2_tests.rs"]
|
|
mod discovery_v2_tests;
|
|
|
|
#[tokio::test]
|
|
async fn shared_catalog_keeps_captured_executor_and_separates_bindings_in_one_turn() -> TestResult {
|
|
let root_dir = tempfile::tempdir()?;
|
|
let skill_dir = root_dir.path().join("demo");
|
|
std::fs::create_dir_all(&skill_dir)?;
|
|
std::fs::write(skill_dir.join("SKILL.md"), DEMO_SKILL_CONTENTS)?;
|
|
let manager = Arc::new(EnvironmentManager::default_for_tests());
|
|
let original = manager.get_environment("local").ok_or("local executor")?;
|
|
let replacement = EnvironmentManager::default_for_tests()
|
|
.get_environment("local")
|
|
.ok_or("replacement")?;
|
|
let roots = vec![SelectedCapabilityRoot {
|
|
id: "skills".into(),
|
|
location: CapabilityRootLocation::Environment {
|
|
environment_id: "captured".into(),
|
|
path: PathUri::from_host_native_path(root_dir.path())?,
|
|
},
|
|
}];
|
|
let captured = manager
|
|
.resolve_selected_capability_roots(
|
|
&roots,
|
|
&HashMap::from([("captured".into(), Some(original))]),
|
|
)
|
|
.await;
|
|
let newer = manager
|
|
.resolve_selected_capability_roots(
|
|
&roots,
|
|
&HashMap::from([("captured".into(), Some(replacement))]),
|
|
)
|
|
.await;
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
|
let address = listener.local_addr()?;
|
|
drop(listener);
|
|
manager.upsert_environment(
|
|
"captured".into(),
|
|
format!("ws://{address}"),
|
|
Some(std::time::Duration::from_millis(100)),
|
|
)?;
|
|
let provider = Arc::new(ExecutorSkillProvider::new_with_restriction_product(
|
|
manager, /*restriction_product*/ None,
|
|
));
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
install_with_providers(
|
|
&mut builder,
|
|
SkillProviders::new().with_executor_provider(provider),
|
|
skills_extension_config,
|
|
);
|
|
let registry = builder.build();
|
|
let session_store = ExtensionData::new("session");
|
|
let thread_store = ExtensionData::new("thread");
|
|
let turn_store = ExtensionData::new("same-turn");
|
|
registry.thread_lifecycle_contributors()[0]
|
|
.on_thread_start(ThreadStartInput {
|
|
config: &default_config(),
|
|
session_source: &SessionSource::Cli,
|
|
persistent_thread_state_available: true,
|
|
environments: &[],
|
|
mcp_resource_client: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
})
|
|
.await;
|
|
for (index, (resolved, expected)) in [
|
|
(captured.clone(), Some("Demo skill.")),
|
|
(captured, Some("Demo skill.")),
|
|
(newer, Some("New executor inventory.")),
|
|
(Vec::new(), None),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
if index == 1 {
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
"---\nname: demo\ndescription: New executor inventory.\n---\nNew body\n",
|
|
)?;
|
|
}
|
|
let step_store = ExtensionData::new(format!("step-{index}"));
|
|
step_store.insert(resolved);
|
|
let sections = prepared_world_state(
|
|
®istry,
|
|
&roots,
|
|
WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "same-turn",
|
|
environments: &[],
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_store: &step_store,
|
|
},
|
|
)
|
|
.await;
|
|
let rendered = sections
|
|
.iter()
|
|
.filter_map(|section| section.render_diff(PreviousWorldStateSection::Absent).1)
|
|
.map(|fragment| fragment.body().clone().expect_text())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
if let Some(expected) = expected {
|
|
assert!(rendered.contains(expected), "step {index}: {rendered}");
|
|
} else {
|
|
assert!(!rendered.contains("- demo:"), "{rendered}");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
#[path = "skills_extension/preparation_tests.rs"]
|
|
mod preparation_tests;
|