use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use kodact_api::MockTurn;
use kodact_test_host::{command, kodact_bin, MockApi};
use serde_json::{json, Value};
struct Env {
_dir: tempfile::TempDir,
root: PathBuf,
cwd: PathBuf,
home: PathBuf,
}
fn env() -> Env {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let (cwd, home) = (root.join("project"), root.join("home"));
std::fs::create_dir_all(&cwd).unwrap();
std::fs::create_dir_all(&home).unwrap();
Env { _dir: dir, root, cwd, home }
}
fn write(path: PathBuf, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
async fn kodact(e: &Env, api: &str, args: &[&str]) -> (i32, String, String) {
let mut c = command(&kodact_bin(), &e.cwd, &e.home, api, args);
c.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let out = tokio::time::timeout(Duration::from_secs(60), c.output()).await.unwrap().unwrap();
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn first_user_text(req: &Value) -> String {
req["messages"][0]["content"]
.as_array()
.unwrap()
.iter()
.filter_map(|b| b["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn commands_skills_styles_and_plugins() {
let e = env();
write(
e.cwd.join(".kodact/commands/review.md"),
"---\ndescription: Review a file\nargument-hint: <file>\n---\nReview $ARGUMENTS carefully.",
);
write(
e.cwd.join(".kodact/skills/pdf/SKILL.md"),
"---\nname: pdf\ndescription: Read PDF files\n---\nUse pdftotext -layout.",
);
write(e.cwd.join(".kodact/settings.json"), r#"{"outputStyle": "explanatory"}"#);
let plugin = e.root.join("plug");
write(plugin.join("plugin.json"), r#"{"name": "kit", "description": "test kit"}"#);
write(plugin.join("commands/hello.md"), "Say hello to $1.");
write(plugin.join("agents/greeter.md"), "---\nname: greeter\ndescription: Greets people\n---\nGreet.");
write(
plugin.join("hooks/hooks.json"),
r#"{"hooks": {"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "echo plugin-hook-ran"}]}]}}"#,
);
let plug = plugin.display().to_string();
let api = MockApi::start(vec![MockTurn::text("reviewed")]).await;
let (code, out, err) = kodact(
&e,
&api.url,
&["-p", "--output-format", "stream-json", "--plugin-dir", &plug, "--", "/review src/main.rs"],
)
.await;
assert_eq!(code, 0, "{err}");
let init: Value = serde_json::from_str(out.lines().next().unwrap()).unwrap();
for name in ["review", "pdf", "kit:hello", "compact"] {
assert!(init["slash_commands"].as_array().unwrap().iter().any(|c| c == name), "{name}: {init}");
}
let skills = init["skills"].as_array().unwrap();
assert!(skills.contains(&json!("pdf")) && skills.contains(&json!("code-review")), "{init}");
let reviews = init["slash_commands"].as_array().unwrap().iter().filter(|c| *c == "review").count();
assert_eq!(reviews, 1, "the project's /review command shadows the bundled skill");
assert_eq!(init["plugins"][0]["name"], "kit");
assert_eq!(init["output_style"], "explanatory");
assert!(init["agents"].as_array().unwrap().iter().any(|a| a == "greeter"));
assert!(init["tools"].as_array().unwrap().iter().any(|t| t == "Skill"));
let req = &api.requests()[0];
let user = first_user_text(req);
assert!(user.contains("Review src/main.rs carefully."), "{user}");
assert!(user.contains("plugin-hook-ran"), "plugin hooks run: {user}");
assert!(req["system"].to_string().contains("Insight"), "output style applied");
let skill_tool = req["tools"].as_array().unwrap().iter().find(|t| t["name"] == "Skill").unwrap();
assert!(skill_tool["description"].as_str().unwrap().contains("- pdf: Read PDF files"));
let api = MockApi::start(vec![MockTurn::tool("Skill", json!({"skill": "pdf"})), MockTurn::text("ok")]).await;
let (code, _, err) = kodact(&e, &api.url, &["-p", "--plugin-dir", &plug, "--", "/kit:hello Ada"]).await;
assert_eq!(code, 0, "{err}");
let reqs = api.requests();
assert!(first_user_text(&reqs[0]).contains("Say hello to Ada."));
assert!(reqs[1].to_string().contains("Use pdftotext -layout."), "the skill body comes back as the tool result");
let api = MockApi::start(vec![]).await;
let (code, out, _) = kodact(&e, &api.url, &["-p", "/help"]).await;
assert_eq!(code, 0);
assert!(
out.contains("/review <file> - Review a file (project)") && out.contains("/pdf - Read PDF files (skill)"),
"{out}"
);
let (code, _, err) = kodact(&e, &api.url, &["-p", "/nope"]).await;
assert_eq!(code, 1);
assert!(err.contains("Unknown command: /nope"), "{err}");
assert!(api.requests().is_empty(), "no model calls for local commands");
let api = MockApi::start(vec![MockTurn::text("it is a shell")]).await;
let (code, out, _) = kodact(&e, &api.url, &["-p", "/bin/sh: what is it?"]).await;
assert_eq!((code, out.trim()), (0, "it is a shell"));
}