Keep workspace skills visible when the prompt budget truncates

The skills prompt renderer was re-sorting every discovered skill by name,
which discarded workspace/source precedence at the last mile. Under a large
global skills set, higher-priority workspace skills from directories such as
`.claude/skills` could be pushed past the prompt budget and disappear from the
model-visible skills list even though discovery had found them correctly.

This keeps stable ordering in discovery and preserves registry order during
rendering, then adds a regression test that proves a workspace-priority skill
survives when lower-priority global skills overflow the prompt budget.

Constraint: Session-time skill rendering must preserve cross-tool/workspace precedence
Rejected: Raise the prompt budget cap | would hide the ordering bug and bloat prompts
Rejected: Special-case `.claude/skills` during rendering | precedence belongs to registry order, not path-specific branches
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not re-sort rendered skills without re-proving precedence behavior under prompt truncation
Tested: cargo test --all-features; cargo fmt --all -- --check; cargo clippy --all-targets --all-features
Not-tested: Manual TUI interaction beyond automated skills prompt and QA PTY coverage
This commit is contained in:
sanbo
2026-05-10 11:27:34 +08:00
committed by Hunter Bown
parent 1ab5528727
commit 7e289568a2
+46 -4
View File
@@ -113,6 +113,9 @@ impl SkillRegistry {
let mut visited = HashSet::new();
Self::discover_recursive(dir, 0, &mut registry, &mut visited);
registry
.skills
.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
registry
}
fn discover_recursive(
@@ -496,9 +499,6 @@ fn render_skills_block(registry: &SkillRegistry) -> Option<String> {
return None;
}
let mut skills = registry.list().to_vec();
skills.sort_by(|a, b| a.name.cmp(&b.name));
let mut out = String::new();
out.push_str("## Skills\n");
out.push_str(
@@ -510,7 +510,7 @@ instructions when using a specific skill.\n\n",
out.push_str("### Available skills\n");
let mut omitted = 0usize;
for skill in skills {
for skill in registry.list() {
// Use the real on-disk path captured at discovery — the directory
// name can differ from the frontmatter `name` for community
// installs, in which case `<dir>/<name>/SKILL.md` would not exist
@@ -770,6 +770,48 @@ mod tests {
);
}
#[test]
fn render_skills_block_preserves_registry_precedence_under_prompt_budget() {
let tmpdir = TempDir::new().unwrap();
let mut registry = super::SkillRegistry::default();
registry.skills.push(super::Skill {
name: "workspace-priority".to_string(),
description: "must survive truncation".to_string(),
body: "body".to_string(),
path: tmpdir
.path()
.join(".claude")
.join("skills")
.join("workspace-priority")
.join("SKILL.md"),
});
let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
for i in 0..200 {
registry.skills.push(super::Skill {
name: format!("aaa-global-{i:03}"),
description: big_desc.clone(),
body: "body".to_string(),
path: tmpdir
.path()
.join(".deepseek")
.join("skills")
.join(format!("aaa-global-{i:03}"))
.join("SKILL.md"),
});
}
let rendered = super::render_skills_block(&registry).expect("skill context");
assert!(
rendered.contains("workspace-priority"),
"higher-precedence workspace skills must not be reordered behind globals:\n{rendered}"
);
assert!(
rendered.contains("additional skills omitted from this prompt budget"),
"fixture should exceed prompt budget"
);
}
fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
let skill_dir = dir.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();