fix(release): close v0.8.17 gate gaps

This commit is contained in:
Hunter Bown
2026-05-07 13:14:22 -05:00
parent ee0ce460ee
commit 0f46acdd76
6 changed files with 140 additions and 17 deletions
+35 -5
View File
@@ -13,6 +13,10 @@ use crate::tui::history::HistoryCell;
use super::CommandResult;
fn discover_visible_skills(app: &App) -> SkillRegistry {
crate::skills::discover_for_workspace_and_dir(&app.workspace, &app.skills_dir)
}
fn render_skill_warnings(registry: &SkillRegistry) -> String {
if registry.warnings().is_empty() {
return String::new();
@@ -44,7 +48,7 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
}
}
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
let registry = discover_visible_skills(app);
let warnings = render_skill_warnings(&registry);
if registry.is_empty() {
@@ -86,8 +90,7 @@ pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
/// Try to run a skill by exact name (used for unified slash-command namespace, #435).
/// Returns None when no skill with that name exists, so the caller can try other sources.
pub fn run_skill_by_name(app: &mut App, name: &str, _arg: Option<&str>) -> Option<CommandResult> {
let skills_dir = app.skills_dir.clone();
let registry = crate::skills::SkillRegistry::discover(&skills_dir);
let registry = discover_visible_skills(app);
if registry.get(name).is_some() {
Some(activate_skill(app, name))
} else {
@@ -125,8 +128,7 @@ fn activate_skill(app: &mut App, name: &str) -> CommandResult {
// `/skill new` is a friendly alias for `/skill skill-creator`.
let name = if name == "new" { "skill-creator" } else { name };
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
let registry = discover_visible_skills(app);
if let Some(skill) = registry.get(name) {
let instruction = format!(
@@ -508,6 +510,34 @@ mod tests {
assert!(msg.contains("/test-skill"));
}
#[test]
fn test_list_skills_merges_workspace_and_configured_dirs() {
let tmpdir = TempDir::new().unwrap();
let workspace_skill_dir = tmpdir
.path()
.join(".agents")
.join("skills")
.join("workspace-skill");
std::fs::create_dir_all(&workspace_skill_dir).unwrap();
std::fs::write(
workspace_skill_dir.join("SKILL.md"),
"---\nname: workspace-skill\ndescription: Workspace skill\n---\nDo workspace work",
)
.unwrap();
create_skill_dir(
&tmpdir,
"configured-skill",
"---\nname: configured-skill\ndescription: Configured skill\n---\nDo configured work",
);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = list_skills(&mut app, None);
let msg = result.message.unwrap();
assert!(msg.contains("/workspace-skill"), "got: {msg}");
assert!(msg.contains("/configured-skill"), "got: {msg}");
}
#[test]
fn test_skill_subcommand_dispatch_install_usage() {
let tmpdir = TempDir::new().unwrap();
+2 -2
View File
@@ -618,8 +618,8 @@ mod tests {
fn package_version_is_current_hotfix_release() {
assert_eq!(
env!("CARGO_PKG_VERSION"),
"0.8.16",
"0.8.16 hotfix branch must report the release version before publishing"
"0.8.17",
"0.8.17 hotfix branch must report the release version before publishing"
);
}
+25
View File
@@ -436,6 +436,31 @@ pub fn discover_in_workspace(workspace: &Path) -> SkillRegistry {
merged
}
/// Discover skills from the workspace search set plus the configured install
/// directory. Workspace/global directories keep their normal precedence; a
/// custom configured directory is appended when it is outside that set.
#[must_use]
pub fn discover_for_workspace_and_dir(workspace: &Path, skills_dir: &Path) -> SkillRegistry {
let mut dirs = skills_directories(workspace);
if skills_dir.is_dir() && !dirs.iter().any(|p| p == skills_dir) {
dirs.push(skills_dir.to_path_buf());
}
let mut merged = SkillRegistry::default();
for dir in dirs {
let registry = SkillRegistry::discover(&dir);
for skill in registry.skills {
if !merged.skills.iter().any(|s| s.name == skill.name) {
merged.skills.push(skill);
}
}
for warning in registry.warnings {
merged.warnings.push(warning);
}
}
merged
}
/// Render the system-prompt skills block from every workspace
/// candidate directory plus the global default (#432). Wraps
/// [`discover_in_workspace`] for callers (e.g. `prompts.rs`) that
+58
View File
@@ -45,6 +45,16 @@ fn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harne
Ok((ws, h))
}
fn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyhow::Result<()> {
let dir = root.join(name);
std::fs::create_dir_all(&dir)?;
std::fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\nUse {name}.\n"),
)?;
Ok(())
}
/// Smoke: the binary boots into an alt-screen, paints a composer, and the
/// header shows the project label. If this fails, the harness itself is
/// broken before we worry about any scenario.
@@ -81,6 +91,54 @@ fn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {
Ok(())
}
/// Regression: `/skills` should reflect the same merged discovery set as the
/// slash menu and model-visible skills block, not just the first selected
/// skills directory.
#[test]
fn skills_menu_shows_local_and_global_skills() -> anyhow::Result<()> {
let ws = make_sealed_workspace()?;
write_skill(ws.user_skills_dir(), "global-alpha", "Global alpha skill")?;
write_skill(
ws.workspace().join(".agents").join("skills"),
"workspace-beta",
"Workspace beta skill",
)?;
let mut h = Harness::builder(Harness::cargo_bin("deepseek-tui"))
.cwd(ws.workspace())
.seal_home(ws.home())
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
.env("RUST_LOG", "warn")
.args([
"--workspace",
ws.workspace().to_str().expect("utf-8 workspace path"),
"--no-project-config",
"--skip-onboarding",
])
.size(40, 140)
.spawn()?;
h.wait_for_text("Composer", BOOT_TIMEOUT)?;
h.send(keys::key::text("/skills"))?;
h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;
h.send(keys::key::enter())?;
h.wait_for_text("Available skills", KEY_TIMEOUT)?;
h.wait_for_text("global-alpha", KEY_TIMEOUT)?;
h.wait_for_text("workspace-beta", KEY_TIMEOUT)?;
let f = h.frame();
let dump = f.debug_dump();
assert!(f.contains("global-alpha"), "global skill missing:\n{dump}");
assert!(
f.contains("workspace-beta"),
"workspace skill missing:\n{dump}"
);
let _ = h.shutdown();
Ok(())
}
// ===========================================================================
// #1073 — pasting multi-line text with a trailing newline must NOT auto-submit
// ===========================================================================
+11 -6
View File
@@ -215,13 +215,18 @@ impl Harness {
/// Resolve a binary by Cargo bin-name (uses `CARGO_BIN_EXE_<name>`).
/// Tests should call this rather than hard-coding paths.
pub fn cargo_bin(name: &str) -> PathBuf {
// CARGO_BIN_EXE_<name> is set by Cargo for binaries declared in the
// same crate as the integration test. For deepseek-tui the binary
// name is `deepseek-tui`.
// Newer Cargo exposes CARGO_BIN_EXE_* at runtime; older supported
// Cargo versions expose it to the integration test at compile time.
let key = format!("CARGO_BIN_EXE_{name}");
std::env::var_os(&key)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("env {key} not set; is the binary declared in this crate?"))
if let Some(path) = std::env::var_os(&key) {
return PathBuf::from(path);
}
if name == "deepseek-tui"
&& let Some(path) = option_env!("CARGO_BIN_EXE_deepseek-tui")
{
return PathBuf::from(path);
}
panic!("env {key} not set; is the binary declared in this crate?")
}
/// Best-effort cooperative shutdown.
+9 -4
View File
@@ -209,12 +209,17 @@ impl PtySession {
}
/// Send SIGTERM-equivalent and wait briefly. Returns the exit status if
/// the child reaped within `grace`, or `None` otherwise (in which case
/// `kill_hard` is called as a last resort).
/// the child reaped within `grace`, or `None` otherwise.
pub fn shutdown(mut self, grace: Duration) -> Option<i32> {
self.kill_and_join_reader(grace)
}
fn kill_and_join_reader(&mut self, grace: Duration) -> Option<i32> {
let _ = self.child.kill();
let exit = self.wait_until(Instant::now() + grace);
if let Some(handle) = self.reader_handle.take() {
if exit.is_some()
&& let Some(handle) = self.reader_handle.take()
{
// Don't block on the reader thread forever — it exits on EOF.
let _ = handle.join();
}
@@ -224,6 +229,6 @@ impl PtySession {
impl Drop for PtySession {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.kill_and_join_reader(Duration::from_secs(2));
}
}