M5: slash commands (command/*.md)
Adds slash-command support: markdown command files under command/*.md, expansion in harness-app, and TUI input handling so typing /mycommand runs it.
This commit is contained in:
+100
-11
@@ -162,6 +162,8 @@ struct EngineInner {
|
||||
/// Pre-rendered "## Skills" system block (name + description), injected into every run.
|
||||
/// `None` when no skills are configured.
|
||||
skills_prompt: Option<String>,
|
||||
/// Slash commands by name, expanded into the user message by the TUI input layer.
|
||||
commands: HashMap<String, config::CommandDef>,
|
||||
cwd: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
runs: Mutex<HashMap<SessionId, RunHandle>>,
|
||||
@@ -228,6 +230,13 @@ impl EngineHandle {
|
||||
harness_tools::register_skill_tool(&mut tools, &skills);
|
||||
let skills_prompt = config::skills_prompt(&skills);
|
||||
|
||||
// Slash commands: layered global + project markdown, expanded into the user message by
|
||||
// the TUI input layer (project wins by name).
|
||||
let global_command_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("command"));
|
||||
let project_command_dir = cwd.join(".harness").join("command");
|
||||
let commands =
|
||||
config::load_commands(global_command_dir.as_deref(), Some(&project_command_dir));
|
||||
|
||||
// Layered agent registry: bundled markdown → global dir → project dir → config patches.
|
||||
let global_agent_dir = dirs::config_dir().map(|d| d.join("ai-harness").join("agent"));
|
||||
let project_agent_dir = cwd.join(".harness").join("agent");
|
||||
@@ -257,8 +266,9 @@ impl EngineHandle {
|
||||
extensions: c.extensions.clone(),
|
||||
})
|
||||
.collect();
|
||||
let diagnostics: Option<Arc<dyn DiagnosticsSource>> =
|
||||
Some(Arc::new(harness_lsp::LspPool::new(cwd.clone(), lsp_servers)));
|
||||
let diagnostics: Option<Arc<dyn DiagnosticsSource>> = Some(Arc::new(
|
||||
harness_lsp::LspPool::new(cwd.clone(), lsp_servers),
|
||||
));
|
||||
|
||||
let inner = Arc::new(EngineInner {
|
||||
config,
|
||||
@@ -271,6 +281,7 @@ impl EngineHandle {
|
||||
agents,
|
||||
diagnostics,
|
||||
skills_prompt,
|
||||
commands,
|
||||
cwd,
|
||||
data_dir,
|
||||
runs: Mutex::new(HashMap::new()),
|
||||
@@ -302,6 +313,11 @@ impl EngineHandle {
|
||||
self.inner.config.clone()
|
||||
}
|
||||
|
||||
/// A loaded slash command by name (without the leading `/`), if defined.
|
||||
pub fn command(&self, name: &str) -> Option<config::CommandDef> {
|
||||
self.inner.commands.get(name).cloned()
|
||||
}
|
||||
|
||||
pub async fn new_session(&self, agent: &str, model_ref: &str) -> Result<SessionId, AppError> {
|
||||
let (provider_id, model_id) = model_ref
|
||||
.split_once('/')
|
||||
@@ -320,6 +336,18 @@ impl EngineHandle {
|
||||
session_id: SessionId,
|
||||
text: String,
|
||||
model_ref: &str,
|
||||
) -> Result<(), AppError> {
|
||||
self.prompt_with(session_id, text, model_ref, None).await
|
||||
}
|
||||
|
||||
/// Like [`prompt`](Self::prompt) but lets a slash command override the agent for this one
|
||||
/// run (`agent_override`) without mutating the stored session agent.
|
||||
pub async fn prompt_with(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
text: String,
|
||||
model_ref: &str,
|
||||
agent_override: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let (provider_id, model_id) = model_ref
|
||||
.split_once('/')
|
||||
@@ -366,14 +394,18 @@ impl EngineHandle {
|
||||
.bus
|
||||
.publish(AppEvent::PartUpdated { part: user_part });
|
||||
|
||||
// Resolve the session's agent from the registry (falls back to a generic assistant).
|
||||
let session_agent = self
|
||||
.inner
|
||||
.store
|
||||
.session(session_id.clone())
|
||||
.await?
|
||||
.map(|s| s.agent)
|
||||
.unwrap_or_else(|| "orchestrator".to_string());
|
||||
// Resolve the agent: a command's `agent:` override for this run, else the session's
|
||||
// stored agent (falling back to a generic assistant).
|
||||
let session_agent = match agent_override {
|
||||
Some(name) => name.to_string(),
|
||||
None => self
|
||||
.inner
|
||||
.store
|
||||
.session(session_id.clone())
|
||||
.await?
|
||||
.map(|s| s.agent)
|
||||
.unwrap_or_else(|| "orchestrator".to_string()),
|
||||
};
|
||||
let agent = self.inner.agent_def(&session_agent);
|
||||
let inject_job_board = self.inner.config.orchestration.job_board && agent.mode.is_primary();
|
||||
let board = self.inner.board_for(&session_id).await?;
|
||||
@@ -1350,10 +1382,67 @@ mod tests {
|
||||
// 6 built-ins + task + skill.
|
||||
assert_eq!(engine.inner.tools.all().len(), 8);
|
||||
assert!(engine.inner.tools.get("skill").is_some());
|
||||
let prompt = engine.inner.skills_prompt.clone().expect("skills advertised");
|
||||
let prompt = engine
|
||||
.inner
|
||||
.skills_prompt
|
||||
.clone()
|
||||
.expect("skills advertised");
|
||||
assert!(prompt.contains("**fmt** — format the code"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_command_loads_and_expands() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let cmd_dir = dir.path().join(".harness").join("command");
|
||||
std::fs::create_dir_all(&cmd_dir).unwrap();
|
||||
std::fs::write(
|
||||
cmd_dir.join("greet.md"),
|
||||
"---\ndescription: greet\nagent: explorer\n---\nHello $1, welcome.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap();
|
||||
let cmd = engine.command("greet").expect("command loaded");
|
||||
assert_eq!(cmd.agent.as_deref(), Some("explorer"));
|
||||
assert_eq!(cmd.expand("world"), "Hello world, welcome.");
|
||||
assert!(engine.command("missing").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_run_reaches_engine_with_agent_override() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let engine = mock_engine(dir.path().to_path_buf());
|
||||
spawn_auto_approve_task(&engine);
|
||||
let session = engine
|
||||
.new_session("orchestrator", "mock/mock-model")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Drive the command path directly: expand + agent override for this run only.
|
||||
engine
|
||||
.prompt_with(
|
||||
session.clone(),
|
||||
"do the thing".into(),
|
||||
"mock/mock-model",
|
||||
Some("explorer"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wait for the run to finish.
|
||||
let mut rx = engine.bus().subscribe();
|
||||
loop {
|
||||
if let Ok(AppEvent::RunFinished { session_id, .. }) = rx.recv().await {
|
||||
if session_id == session {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The stored session agent is untouched by the per-run override.
|
||||
let stored = engine.get_session(session).await.unwrap().unwrap();
|
||||
assert_eq!(stored.agent, "orchestrator");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_prompt_errors_on_unregistered_provider() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user