Inject SKILL.md when it's explicitly mentioned. (#7763)

1. Skills load once in core at session start; the cached outcome is
reused across core and surfaced to TUI via SessionConfigured.
2. TUI detects explicit skill selections, and core injects the matching
SKILL.md content into the turn when a selected skill is present.
This commit is contained in:
xl-openai
2025-12-10 13:59:17 -08:00
committed by GitHub
parent eb2e5458cc
commit b36ecb6c32
21 changed files with 584 additions and 88 deletions
+29 -31
View File
@@ -25,6 +25,7 @@ use codex_core::AuthManager;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::config::edit::ConfigEditsBuilder;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
@@ -33,9 +34,9 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::FinalOutput;
use codex_core::protocol::Op;
use codex_core::protocol::SessionSource;
use codex_core::protocol::SkillLoadOutcomeInfo;
use codex_core::protocol::TokenUsage;
use codex_core::skills::load_skills;
use codex_core::skills::model::SkillMetadata;
use codex_core::skills::SkillError;
use codex_protocol::ConversationId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
@@ -88,6 +89,17 @@ fn session_summary(
})
}
fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillError> {
outcome
.errors
.iter()
.map(|err| SkillError {
path: err.path.clone(),
message: err.message.clone(),
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionSummary {
usage_line: String,
@@ -237,8 +249,6 @@ pub(crate) struct App {
// One-shot suppression of the next world-writable scan after user confirmation.
skip_world_writable_scan_once: bool,
pub(crate) skills: Option<Vec<SkillMetadata>>,
}
impl App {
@@ -291,26 +301,6 @@ impl App {
model = updated_model;
}
let skills_outcome = load_skills(&config);
if !skills_outcome.errors.is_empty() {
match run_skill_error_prompt(tui, &skills_outcome.errors).await {
SkillErrorPromptOutcome::Exit => {
return Ok(AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
update_action: None,
});
}
SkillErrorPromptOutcome::Continue => {}
}
}
let skills = if config.features.enabled(Feature::Skills) {
Some(skills_outcome.skills.clone())
} else {
None
};
let enhanced_keys_supported = tui.enhanced_keys_supported();
let model_family = conversation_manager
.get_models_manager()
@@ -328,7 +318,6 @@ impl App {
auth_manager: auth_manager.clone(),
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
skills: skills.clone(),
is_first_run,
model_family: model_family.clone(),
};
@@ -355,7 +344,6 @@ impl App {
auth_manager: auth_manager.clone(),
models_manager: conversation_manager.get_models_manager(),
feedback: feedback.clone(),
skills: skills.clone(),
is_first_run,
model_family: model_family.clone(),
};
@@ -393,7 +381,6 @@ impl App {
pending_update_action: None,
suppress_shutdown_complete: false,
skip_world_writable_scan_once: false,
skills,
};
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
@@ -519,7 +506,6 @@ impl App {
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
skills: self.skills.clone(),
is_first_run: false,
model_family: model_family.clone(),
};
@@ -570,7 +556,6 @@ impl App {
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
skills: self.skills.clone(),
is_first_run: false,
model_family: model_family.clone(),
};
@@ -662,6 +647,19 @@ impl App {
self.suppress_shutdown_complete = false;
return Ok(true);
}
if let EventMsg::SessionConfigured(cfg) = &event.msg
&& let Some(outcome) = cfg.skill_load_outcome.as_ref()
&& !outcome.errors.is_empty()
{
let errors = skill_errors_from_outcome(outcome);
match run_skill_error_prompt(tui, &errors).await {
SkillErrorPromptOutcome::Exit => {
self.chat_widget.submit_op(Op::Shutdown);
return Ok(false);
}
SkillErrorPromptOutcome::Continue => {}
}
}
self.chat_widget.handle_codex_event(event);
}
AppEvent::ConversationHistory(ev) => {
@@ -1209,7 +1207,6 @@ mod tests {
pending_update_action: None,
suppress_shutdown_complete: false,
skip_world_writable_scan_once: false,
skills: None,
}
}
@@ -1250,7 +1247,6 @@ mod tests {
pending_update_action: None,
suppress_shutdown_complete: false,
skip_world_writable_scan_once: false,
skills: None,
},
rx,
op_rx,
@@ -1358,6 +1354,7 @@ mod tests {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
skill_load_outcome: None,
rollout_path: PathBuf::new(),
};
Arc::new(new_session_info(
@@ -1413,6 +1410,7 @@ mod tests {
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
skill_load_outcome: None,
rollout_path: PathBuf::new(),
};
-1
View File
@@ -350,7 +350,6 @@ impl App {
auth_manager: self.auth_manager.clone(),
models_manager: self.server.get_models_manager(),
feedback: self.feedback.clone(),
skills: self.skills.clone(),
is_first_run: false,
};
self.chat_widget =
@@ -801,6 +801,10 @@ impl ChatComposer {
self.skills.as_ref().is_some_and(|s| !s.is_empty())
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.skills.as_ref()
}
/// Extract a token prefixed with `prefix` under the cursor, if any.
///
/// The returned string **does not** include the prefix.
+9
View File
@@ -131,10 +131,19 @@ impl BottomPane {
}
}
pub fn set_skills(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.composer.set_skill_mentions(skills);
self.request_redraw();
}
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
self.status.as_ref()
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.composer.skills()
}
#[cfg(test)]
pub(crate) fn context_window_percent(&self) -> Option<i64> {
self.context_window_percent
+47 -5
View File
@@ -44,6 +44,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::RateLimitSnapshot;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
use codex_core::protocol::SkillLoadOutcomeInfo;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TerminalInteractionEvent;
@@ -263,7 +264,6 @@ pub(crate) struct ChatWidgetInit {
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: Arc<ModelsManager>,
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) skills: Option<Vec<SkillMetadata>>,
pub(crate) is_first_run: bool,
pub(crate) model_family: ModelFamily,
}
@@ -392,6 +392,7 @@ impl ChatWidget {
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.set_skills_from_outcome(event.skill_load_outcome.as_ref());
self.conversation_id = Some(event.session_id);
self.current_rollout_path = Some(event.rollout_path.clone());
let initial_messages = event.initial_messages.clone();
@@ -416,6 +417,11 @@ impl ChatWidget {
}
}
fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) {
let skills = outcome.map(skills_from_outcome);
self.bottom_pane.set_skills(skills);
}
pub(crate) fn open_feedback_note(
&mut self,
category: crate::app_event::FeedbackCategory,
@@ -1262,7 +1268,6 @@ impl ChatWidget {
auth_manager,
models_manager,
feedback,
skills,
is_first_run,
model_family,
} = common;
@@ -1285,7 +1290,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
skills,
skills: None,
}),
active_cell: None,
config,
@@ -1348,7 +1353,6 @@ impl ChatWidget {
auth_manager,
models_manager,
feedback,
skills,
model_family,
..
} = common;
@@ -1371,7 +1375,7 @@ impl ChatWidget {
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
skills,
skills: None,
}),
active_cell: None,
config,
@@ -1738,6 +1742,16 @@ impl ChatWidget {
items.push(UserInput::LocalImage { path });
}
if let Some(skills) = self.bottom_pane.skills() {
let skill_mentions = find_skill_mentions(&text, skills);
for skill in skill_mentions {
items.push(UserInput::Skill {
name: skill.name.clone(),
path: skill.path.clone(),
});
}
}
self.codex_op_tx
.send(Op::UserInput { items })
.unwrap_or_else(|e| {
@@ -3459,5 +3473,33 @@ pub(crate) fn show_review_commit_picker_with_entries(
});
}
fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec<SkillMetadata> {
outcome
.skills
.iter()
.map(|skill| SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
path: skill.path.clone(),
})
.collect()
}
fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec<SkillMetadata> {
let mut seen: HashSet<String> = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen.contains(&skill.name) {
continue;
}
let needle = format!("${}", skill.name);
if text.contains(&needle) {
seen.insert(skill.name.clone());
matches.push(skill.clone());
}
}
matches
}
#[cfg(test)]
pub(crate) mod tests;
+1 -1
View File
@@ -122,6 +122,7 @@ fn resumed_initial_messages_render_history() {
message: "assistant reply".to_string(),
}),
]),
skill_load_outcome: None,
rollout_path: rollout_file.path().to_path_buf(),
};
@@ -364,7 +365,6 @@ async fn helpers_are_available_and_do_not_panic() {
auth_manager,
models_manager: conversation_manager.get_models_manager(),
feedback: codex_feedback::CodexFeedback::new(),
skills: None,
is_first_run: true,
model_family,
};