mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
+29
-31
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user