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();
|
||||
|
||||
@@ -9,7 +9,16 @@ use crate::state::{AppState, ModalState};
|
||||
#[derive(Debug)]
|
||||
pub enum InputAction {
|
||||
None,
|
||||
Submit { text: String },
|
||||
Submit {
|
||||
text: String,
|
||||
},
|
||||
/// A user-defined slash command: `name` (no leading `/`), its `args`, and the `raw` input
|
||||
/// to fall back to submitting verbatim if no such command is defined.
|
||||
RunCommand {
|
||||
name: String,
|
||||
args: String,
|
||||
raw: String,
|
||||
},
|
||||
Abort,
|
||||
Quit,
|
||||
LoadSessions,
|
||||
@@ -176,7 +185,13 @@ fn parse_slash_command(text: &str) -> Option<InputAction> {
|
||||
"/sessions" => Some(InputAction::LoadSessions),
|
||||
"/jobs" => Some(InputAction::OpenJobs),
|
||||
"/quit" => Some(InputAction::Quit),
|
||||
_ => None,
|
||||
// Any other `/word` is treated as a user-defined command, resolved against the engine's
|
||||
// loaded commands when applied; if none matches, the raw text is submitted as-is.
|
||||
other => Some(InputAction::RunCommand {
|
||||
name: other.trim_start_matches('/').to_string(),
|
||||
args: rest,
|
||||
raw: trimmed.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +205,30 @@ pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &En
|
||||
}
|
||||
}
|
||||
}
|
||||
InputAction::RunCommand { name, args, raw } => {
|
||||
let Some(session_id) = state.session_id.clone() else {
|
||||
return;
|
||||
};
|
||||
match engine.command(&name) {
|
||||
Some(cmd) => {
|
||||
let text = cmd.expand(&args);
|
||||
// A command may switch model/agent for this one run only.
|
||||
let model_ref = cmd.model.clone().unwrap_or_else(|| state.model_ref.clone());
|
||||
if let Err(e) = engine
|
||||
.prompt_with(session_id, text, &model_ref, cmd.agent.as_deref())
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, "command prompt failed");
|
||||
}
|
||||
}
|
||||
// Unknown command: submit the original text as an ordinary message.
|
||||
None => {
|
||||
if let Err(e) = engine.prompt(session_id, raw, &state.model_ref).await {
|
||||
tracing::error!(error = %e, "prompt failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
InputAction::Abort => {
|
||||
if let Some(session_id) = state.session_id.clone() {
|
||||
engine.abort(&session_id);
|
||||
@@ -317,4 +356,33 @@ mod tests {
|
||||
assert!(state.dirty, "a keystroke must request a redraw");
|
||||
assert_eq!(state.input.lines().join("\n"), "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_slash_commands_still_parse() {
|
||||
assert!(matches!(
|
||||
parse_slash_command("/new"),
|
||||
Some(InputAction::NewSession)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_slash_command("/model openai/gpt-5"),
|
||||
Some(InputAction::SetModel(m)) if m == "openai/gpt-5"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_slash_becomes_a_run_command() {
|
||||
match parse_slash_command("/deploy prod now") {
|
||||
Some(InputAction::RunCommand { name, args, raw }) => {
|
||||
assert_eq!(name, "deploy");
|
||||
assert_eq!(args, "prod now");
|
||||
assert_eq!(raw, "/deploy prod now");
|
||||
}
|
||||
other => panic!("expected RunCommand, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_slash_text_is_not_a_command() {
|
||||
assert!(parse_slash_command("hello world").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user