Skip to content
Merged
3 changes: 2 additions & 1 deletion apps/huginn/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Host configuration and UI preference persistence.
//! Host configuration, skill completion, and UI preference persistence.
pub mod config;
pub mod skill_completion;
pub mod ui_settings;

use std::path::PathBuf;
Expand Down
58 changes: 35 additions & 23 deletions apps/huginn/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use anyhow::Context;
use clap::{Parser, Subcommand};
use huginn::{
config::{Config, ModelReadiness},
resolve_workspace, ui_settings,
resolve_workspace, skill_completion, ui_settings,
};
use huginn_application::authorization::HostPolicy;
use huginn_core::{
Expand Down Expand Up @@ -494,7 +494,11 @@ async fn run_prompt_loop(
let Some(line) = line else {
break;
};
let line = line.trim().to_string();
let line = if line.trim_start().starts_with("/skill:") {
line
} else {
line.trim().to_string()
};
if matches!(line.as_str(), "/quit" | "/exit") {
break;
}
Expand Down Expand Up @@ -629,28 +633,32 @@ async fn tui_chat(
supervisor.register(Box::new(huginn_reference_extension::ReferenceExtension))?;
supervisor.start().await?;
let presentation = supervisor.resolve::<PresentationService>(&presentation_id())?;
let catalog = prompts::catalog(session)?;
let skill_result = {
let implementation = supervisor.resolve::<LoopService>(&composition::loop_id())?;
implementation
.0
.skill_commands(&session.workspace, CancellationToken::new())
.await
};
let (mut host_commands, skill_error) = skill_completion::completion_result(skill_result);
host_commands.extend(catalog.entries.iter().map(|entry| {
(
format!("/prompts:{}", entry.name),
format!(
"{} {} [{}]",
entry.description,
entry.argument_hint,
if entry.project { "proyecto" } else { "usuario" }
),
)
}));
let connection = presentation.0.open(session).await?;
let ui = connection.ui.clone();
let catalog = prompts::catalog(session)?;
ui.set_prompt_templates(
catalog
.entries
.iter()
.map(|entry| {
(
format!("/prompts:{}", entry.name),
format!(
"{} {} [{}]",
entry.description,
entry.argument_hint,
if entry.project { "proyecto" } else { "usuario" }
),
)
})
.collect(),
);
for diagnostic in catalog.diagnostics {
ui.notification(diagnostic);
ui.set_prompt_templates(host_commands);
if let Some(message) = skill_completion::startup_notification(skill_error, catalog.diagnostics)
{
ui.notification(message);
}
let policy = Arc::new(HostPolicy::new(ui.clone(), allowed));
let result: anyhow::Result<()> = async {
Expand All @@ -669,7 +677,11 @@ async fn tui_chat(
}
let prompt = match prompts::expand(session, prompt) {
Ok(prompt) => prompt,
Err(error) => { ui.notification(error.to_string()); continue; }
Err(error) => {
ui.notification(error.to_string());
ui.synchronize(session);
continue;
}
};
let cancel = CancellationToken::new();
let operation = ui.begin(cancel.clone());
Expand Down
9 changes: 7 additions & 2 deletions apps/huginn/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ pub fn expand(session: &Session, input: String) -> anyhow::Result<String> {
if !input.starts_with("/prompts:") {
return Ok(input);
}
Ok(service(session)?
let expanded = service(session)?
.expand(&input)
.map_err(anyhow::Error::msg)?
.unwrap_or(input))
.unwrap_or(input);
anyhow::ensure!(
!expanded.trim_start().starts_with("/skill:"),
"prompt templates cannot invoke skills; enter /skill:<name> directly"
);
Ok(expanded)
}

#[cfg(test)]
Expand Down
22 changes: 22 additions & 0 deletions apps/huginn/src/skill_completion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
pub fn completion_result<E: std::fmt::Display>(
result: Result<Vec<(String, String)>, E>,
) -> (Vec<(String, String)>, Option<String>) {
match result {
Ok(commands) => (commands, None),
Err(error) => (
Vec::new(),
Some(format!("Skill completion unavailable: {error}")),
),
}
}

pub fn startup_notification(
skill_error: Option<String>,
prompt_diagnostics: impl IntoIterator<Item = String>,
) -> Option<String> {
let messages = skill_error
.into_iter()
.chain(prompt_diagnostics)
.collect::<Vec<_>>();
(!messages.is_empty()).then(|| messages.join("\n"))
}
23 changes: 23 additions & 0 deletions apps/huginn/tests/skill_completion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use huginn::skill_completion::{completion_result, startup_notification};

#[test]
fn discovery_failure_keeps_tui_completions_optional() {
let (commands, notification) = completion_result(Err(anyhow::anyhow!("catalog unavailable")));
assert!(commands.is_empty());
assert_eq!(
notification.as_deref(),
Some("Skill completion unavailable: catalog unavailable")
);
}

#[test]
fn startup_notification_preserves_skill_and_prompt_diagnostics() {
let message = startup_notification(
Some("Skill completion unavailable: catalog unavailable".into()),
["Prompt template unavailable: invalid metadata".into()],
);
assert_eq!(
message.as_deref(),
Some("Skill completion unavailable: catalog unavailable\nPrompt template unavailable: invalid metadata")
);
}
185 changes: 185 additions & 0 deletions apps/huginn/tests/skill_invocation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use std::{
fs,
io::Write,
path::Path,
process::{Command, Stdio},
};

fn command(home: &Path, project: &Path, db: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_huginn"));
command
.env("HOME", home)
.env("USERPROFILE", home)
.env("XDG_CONFIG_HOME", home)
.args(["--provider", "mock", "--json", "--workspace"])
.arg(project)
.arg("--db")
.arg(db);
command
}

fn skill(root: &Path, body: &str) {
let directory = root.join(".agents/skills/review");
fs::create_dir_all(&directory).unwrap();
fs::write(
directory.join("SKILL.md"),
format!("---\nname: review\ndescription: Review local changes\n---\n{body}"),
)
.unwrap();
}

fn session(db: &Path) -> serde_json::Value {
let connection = rusqlite::Connection::open(db).unwrap();
let data: String = connection
.query_row("SELECT data FROM sessions LIMIT 1", [], |row| row.get(0))
.unwrap();
serde_json::from_str(&data).unwrap()
}

fn assert_ndjson(output: &[u8]) -> Vec<serde_json::Value> {
assert!(!output.contains(&0x1b));
String::from_utf8_lossy(output)
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}

#[test]
fn run_line_json_and_resume_preserve_task_and_project_selection() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
skill(home.path(), "GLOBAL OVERRIDDEN BODY");
skill(project.path(), "PROJECT BODY");
let task = " \"quoted\" \\path ";
let input = format!("/skill:review {task}");
for line_mode in [false, true] {
let db = home.path().join(format!("mode-{line_mode}.db"));
let output = if line_mode {
let mut child = command(home.path(), project.path(), &db)
.arg("--line-mode")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
writeln!(
child.stdin.take().unwrap(),
"/skill:unknown\n{input}\n/quit"
)
.unwrap();
child.wait_with_output().unwrap()
} else {
command(home.path(), project.path(), &db)
.args(["run", &input])
.output()
.unwrap()
};
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let events = assert_ndjson(&output.stdout);
assert_eq!(
events
.iter()
.filter(|event| event["type"] == "turn_started")
.count(),
1
);
let saved = session(&db);
let user = saved["messages"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "user")
.unwrap();
let envelope: serde_json::Value =
serde_json::from_str(user["content"].as_str().unwrap().lines().last().unwrap())
.unwrap();
assert_eq!(envelope["task"], task);
assert_eq!(envelope["skill"]["scope"], "project");
assert!(envelope["skill"]["instructions"]
.as_str()
.unwrap()
.contains("PROJECT BODY"));
assert!(!user["content"]
.as_str()
.unwrap()
.contains("GLOBAL OVERRIDDEN BODY"));
let id = saved["id"].as_str().unwrap();
skill(project.path(), "UPDATED PROJECT BODY");
let output = command(home.path(), project.path(), &db)
.args(["resume", id, "--prompt", "/skill:review resumed"])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_ndjson(&output.stdout);
let saved = session(&db);
assert_eq!(
saved["messages"]
.as_array()
.unwrap()
.iter()
.filter(|m| m["role"] == "user")
.count(),
2
);
assert!(saved.to_string().contains("UPDATED PROJECT BODY"));
skill(project.path(), "PROJECT BODY");
}
}

#[test]
fn invalid_unknown_and_unavailable_skills_never_start_a_json_turn() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
skill(project.path(), &"x".repeat(20000));
for (index, input) in [
"/skill:",
"/skill:../review",
"/skill:missing",
"/skill:review",
]
.iter()
.enumerate()
{
let db = home.path().join(format!("failure-{index}.db"));
let output = command(home.path(), project.path(), &db)
.args(["run", input])
.output()
.unwrap();
assert!(!output.status.success());
assert!(assert_ndjson(&output.stdout).is_empty());
let connection = rusqlite::Connection::open(db).unwrap();
let count: i64 = connection
.query_row("SELECT count(*) FROM sessions", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
}

#[test]
fn templates_cannot_implicitly_activate_project_skills() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
skill(project.path(), "must be explicitly selected");
let directory = project.path().join(".huginn/prompts");
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join("indirect.md"), "/skill:review task").unwrap();
let output = command(
home.path(),
project.path(),
&home.path().join("indirect.db"),
)
.args(["run", "/prompts:indirect"])
.output()
.unwrap();
assert!(!output.status.success());
assert!(assert_ndjson(&output.stdout).is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("cannot invoke skills"));
}
Loading
Loading