mirror of
https://github.com/hanzoai/codex.git
synced 2026-10-30 14:59:26 +00:00
Upstream crates read Dev's words and defaults from crates in hanzoai/dev, by a path out of this tree. What they read is data and an interface, so it moves here, into codex-rs/hanzo, which absorbs hanzo-version: - the brand strings and the TUI's defaults (hanzo-tui's constants and input_background), read by codex-tui; - API_BASE, DEFAULT_MODEL, is_hanzo and the model catalog (from hanzo-config), read by models-manager and model-provider; - `model::pick`, the `/model <name>` match, pure over the catalog; - the controller interface the agent loop consults (hanzo-loop: Controller, Handle, Verdict, Card, Request, Shortlist), read by codex-core; - the version hook, now `hanzo::version`. Dev's crates depend on this one from now on, never the reverse. Kai and the Hanzo account are behaviour, and still named from here until the next two commits turn them into hooks a binary fills.
3110 lines
118 KiB
Rust
3110 lines
118 KiB
Rust
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_config::ConfigLayerEntry;
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_config::ConfigRequirementsToml;
|
|
use codex_exec_server::CapabilityRootDiscovery;
|
|
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
|
use codex_exec_server::FileSystemEnvironmentAccessor;
|
|
use codex_exec_server::LOCAL_FS;
|
|
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::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::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 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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id,
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store,
|
|
thread_store,
|
|
turn_store: &turn_store,
|
|
step_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 = registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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()))
|
|
.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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: Some(turn_metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: Some(turn_metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: Some(turn_metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-2",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &resumed_thread_store,
|
|
turn_store: &resumed_turn_store,
|
|
step_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 = |entry: SkillCatalogEntry| {
|
|
Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
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_host_provider(provider(
|
|
test_entry(
|
|
SkillSourceKind::Host,
|
|
"host",
|
|
"host/host-skill",
|
|
"/skills/host-skill/SKILL.md",
|
|
)
|
|
.with_display_path("/skills/host-skill/SKILL.md"),
|
|
));
|
|
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;
|
|
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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: Some(metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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",
|
|
"- cloud-skill: Fix lint errors. (cloud package: cloud/cloud-skill)",
|
|
),
|
|
(
|
|
"host_skills",
|
|
"- host-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")?;
|
|
assert!(fragment.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 {
|
|
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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: Some(metrics.clone()),
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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 {
|
|
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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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")?;
|
|
|
|
assert!(host_fragment.body().contains("Fix lint errors."));
|
|
assert!(!host_fragment.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 {
|
|
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",
|
|
hanzo::version::get(),
|
|
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");
|
|
turn_store.insert(HostSkillsSnapshot::new(Arc::new(
|
|
SkillLoadOutcome::default(),
|
|
)));
|
|
|
|
let sections = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_store: &turn_store,
|
|
})
|
|
.await;
|
|
let fragments = registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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 {
|
|
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",
|
|
hanzo::version::get(),
|
|
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 = registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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_follows_step_availability_and_reuses_its_cache() -> TestResult {
|
|
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 {
|
|
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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: std::slice::from_ref(&turn_environment),
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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")?;
|
|
assert!(available_fragment.body().contains("lint-fix"));
|
|
assert!(
|
|
available_fragment
|
|
.body()
|
|
.contains("(executor package: executor/lint-fix)")
|
|
);
|
|
|
|
let fragments = registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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());
|
|
assert!(fragments[0].render().contains("<name>lint-fix</name>"));
|
|
assert!(fragments[0].render().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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: Some(&available_world_state),
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-2",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[],
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &unavailable_turn_store,
|
|
step_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")?;
|
|
assert!(
|
|
unavailable_fragment
|
|
.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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: Some(&unavailable_world_state),
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-3",
|
|
environments: &[turn_environment],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &restored_turn_store,
|
|
step_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(),
|
|
"\n## Skills update\nThe previously listed selected-environment skills are available again.\n",
|
|
);
|
|
assert!(restored_sections[0].matches_retained_fragment("developer", available_fragment.body()));
|
|
assert!(
|
|
!restored_sections[0].matches_retained_fragment("developer", unavailable_fragment.body())
|
|
);
|
|
assert_eq!(
|
|
restored_sections[0]
|
|
.render_diff(PreviousWorldStateSection::Absent)
|
|
.1,
|
|
Some(available_fragment),
|
|
);
|
|
assert_eq!(1, 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, 2),
|
|
("recovered-discovery", &recovered_discovery, 3),
|
|
("cached-discovery", &recovered_discovery, 3),
|
|
] {
|
|
registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id,
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: Some(discovery),
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &ExtensionData::new(turn_id),
|
|
step_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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-4",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &listing_disabled_turn_store,
|
|
step_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()
|
|
);
|
|
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()
|
|
);
|
|
|
|
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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
model_info: &catalog_model_info(),
|
|
thread_id,
|
|
turn_id: "turn",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &[root],
|
|
executor_capability_discovery: None,
|
|
executor_capability_discovery_v2: None,
|
|
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()
|
|
.to_string()
|
|
}
|
|
};
|
|
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 {
|
|
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 {
|
|
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 expected_description = "x".repeat(1_021) + "...";
|
|
for index in 0..5 {
|
|
assert!(executor.body().contains(&format!(
|
|
"- executor-long-description-{index}: {expected_description} (executor package:"
|
|
)));
|
|
}
|
|
assert!(cloud.body().contains("- cloud-long-description:"));
|
|
for rendered in [executor.body(), cloud.body()] {
|
|
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 {
|
|
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 {
|
|
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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: Some(&previous),
|
|
model_info: &model,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: &turn_id,
|
|
environments: &[],
|
|
ready_selected_capability_roots: if ready { &selected_roots } else { &[] },
|
|
executor_capability_discovery: None,
|
|
executor_capability_discovery_v2: None,
|
|
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 {
|
|
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 {
|
|
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 included_executor_count = executor
|
|
.body()
|
|
.lines()
|
|
.filter(|line| line.starts_with("- executor-skill-"))
|
|
.count();
|
|
let included_cloud_count = cloud
|
|
.body()
|
|
.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
|
|
.body()
|
|
.contains("- executor-skill-000: (executor package:")
|
|
);
|
|
assert!(cloud.body().contains("- cloud-skill-000: (cloud package:"));
|
|
assert!(!cloud.body().contains("- cloud-skill-159:"));
|
|
for (total, included, rendered) in [
|
|
(40, included_executor_count, executor.body()),
|
|
(160, included_cloud_count, cloud.body()),
|
|
] {
|
|
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 {
|
|
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 tools = registry.tool_contributors()[0].tools(&session_store, &thread_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),
|
|
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),
|
|
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 in [false, true] {
|
|
let list_calls = Arc::new(AtomicUsize::new(0));
|
|
let providers = SkillProviders::new().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 (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_skill_enabled {
|
|
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_skill_enabled]
|
|
);
|
|
start_registered_turn(®istry, &session_store, &thread_store, "turn-1").await;
|
|
assert_eq!(
|
|
list_calls.load(Ordering::Relaxed),
|
|
usize::from(cloud_skill_enabled)
|
|
);
|
|
if !cloud_skill_enabled {
|
|
assert!(event_rx.try_recv().is_err());
|
|
let tools = registry.tool_contributors()[0].tools(&session_store, &thread_store);
|
|
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 root_qualified_locator_selects_only_the_matching_executor_skill() -> TestResult {
|
|
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 {
|
|
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");
|
|
registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
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,
|
|
}],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_store: &turn_store,
|
|
})
|
|
.await;
|
|
let fragments = registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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().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 {
|
|
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 {
|
|
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 = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_store: &turn_store,
|
|
})
|
|
.await;
|
|
// Core rebuilds world state before each sampling step.
|
|
let _repeated_sections = registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &model_info,
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_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().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().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 executor_provider = Arc::new(StaticSkillProvider {
|
|
catalog: SkillCatalog {
|
|
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");
|
|
|
|
registry.context_contributors()[0]
|
|
.contribute_world_state(WorldStateContributionInput {
|
|
previous_world_state: None,
|
|
executor_capability_discovery_v2: None,
|
|
model_info: &catalog_model_info(),
|
|
thread_id: codex_protocol::ThreadId::new(),
|
|
turn_id: "turn-1",
|
|
environments: &[],
|
|
ready_selected_capability_roots: &selected_roots,
|
|
executor_capability_discovery: None,
|
|
extension_metrics: None,
|
|
session_store: &session_store,
|
|
thread_store: &thread_store,
|
|
turn_store: &turn_store,
|
|
step_store: &turn_store,
|
|
})
|
|
.await;
|
|
registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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 = registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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();
|
|
|
|
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 {
|
|
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 = registry.turn_input_contributors()[0]
|
|
.contribute(
|
|
TurnInputContext {
|
|
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());
|
|
assert!(fragments[0].render().contains("visible-skill"));
|
|
assert!(!fragments[0].render().contains("hidden-skill"));
|
|
assert!(fragments[1].render().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()
|
|
}
|