use std::path::PathBuf;
use std::process::Command;
use std::{fs, str};
const SECRET: &str = "sk-abcDEF1234567890ghijkl";
struct World {
base: PathBuf,
home: PathBuf,
cv_home: PathBuf,
}
impl World {
fn new(tag: &str) -> World {
let base = std::env::temp_dir().join(format!(
"cv-cli-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let home = base.join("home");
let cv_home = base.join("cvhome");
fs::create_dir_all(home.join(".claude/projects/-work-proj")).unwrap();
fs::create_dir_all(&cv_home).unwrap();
World { base, home, cv_home }
}
fn write_session(&self, name: &str, lines: &[serde_json::Value]) -> PathBuf {
let path = self
.home
.join(".claude/projects/-work-proj")
.join(format!("{name}.jsonl"));
let body: String = lines.iter().map(|l| format!("{l}\n")).collect();
fs::write(&path, body).unwrap();
path
}
fn cv(&self, args: &[&str]) -> (bool, i32, String, String) {
self.cv_env(args, &[])
}
fn cv_env(&self, args: &[&str], extra: &[(&str, &str)]) -> (bool, i32, String, String) {
self.cv_full(args, extra, None)
}
fn cv_stdin(&self, args: &[&str], stdin: &str) -> (bool, i32, String, String) {
self.cv_full(args, &[], Some(stdin))
}
fn cv_full(&self, args: &[&str], extra: &[(&str, &str)], stdin: Option<&str>) -> (bool, i32, String, String) {
use std::io::Write;
let mut cmd = Command::new(env!("CARGO_BIN_EXE_cv"));
cmd.args(args)
.current_dir(&self.base)
.env("HOME", &self.home)
.env("CLUSTERVISION_HOME", &self.cv_home)
.env("XDG_CACHE_HOME", self.home.join(".cache"))
.env("XDG_CONFIG_HOME", self.home.join(".config"))
.env("XDG_DATA_HOME", self.home.join(".local/share"))
.env_remove("CV_ENDPOINT")
.env_remove("CLAUDE_CONFIG_DIR")
.env_remove("CLUSTERVISION_CLAUDE_ROOTS");
for (k, v) in extra {
cmd.env(k, v);
}
let out = match stdin {
None => cmd.stdin(std::process::Stdio::null()).output().expect("cv should run"),
Some(text) => {
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("cv should spawn");
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
child.wait_with_output().expect("cv should run")
}
};
(
out.status.success(),
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn seed_task_log(&self, lines: &[serde_json::Value]) {
let dir = self.cv_home.join("tasks");
fs::create_dir_all(&dir).unwrap();
let mut body = String::from("{\"format\":\"cv-task-log\",\"v\":1}\n");
for l in lines {
body.push_str(&l.to_string());
body.push('\n');
}
fs::write(dir.join("events.jsonl"), body).unwrap();
}
fn append_task_log(&self, lines: &[serde_json::Value]) {
use std::io::Write;
let dir = self.cv_home.join("tasks");
fs::create_dir_all(&dir).unwrap();
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("events.jsonl"))
.unwrap();
for l in lines {
writeln!(f, "{l}").unwrap();
}
}
fn plant_legacy_tag_decision(&self, id: &str, title: &str, who: &str) {
let ts = chrono_at(3);
self.append_task_log(&[
serde_json::json!({"id": id, "task_id": id, "ts": ts, "by": "cv", "event": "opened",
"title": title, "channel": "tasks", "assignee": who}),
serde_json::json!({"id": format!("{}f", &id[..id.len() - 1]), "task_id": id, "ts": ts, "by": "cv",
"event": "tagged", "tags": ["decision"]}),
]);
}
fn cv_ok(&self, args: &[&str]) -> (String, String) {
let (ok, code, out, err) = self.cv(args);
assert!(ok, "cv {args:?} exited {code}\nstdout:\n{out}\nstderr:\n{err}");
(out, err)
}
fn cv_fails(&self, args: &[&str]) -> (String, String) {
let (ok, code, out, err) = self.cv(args);
assert!(!ok, "cv {args:?} unexpectedly succeeded\nstdout:\n{out}");
assert_ne!(code, -1, "cv {args:?} died by signal (panic/abort?)\nstderr:\n{err}");
assert!(
!err.trim().is_empty(),
"cv {args:?} failed silently (empty stderr)\nstdout:\n{out}"
);
assert!(
!err.contains("panicked"),
"cv {args:?} panicked instead of erroring:\n{err}"
);
(out, err)
}
}
impl Drop for World {
fn drop(&mut self) {
fs::remove_dir_all(&self.base).ok();
}
}
fn user_line(uuid: &str, ts: &str, text: &str) -> serde_json::Value {
serde_json::json!({
"type": "user", "uuid": uuid, "sessionId": "s", "timestamp": ts,
"cwd": "/work/proj",
"message": {"role": "user", "content": text}
})
}
fn assistant_line(uuid: &str, ts: &str, blocks: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"type": "assistant", "uuid": uuid, "sessionId": "s", "timestamp": ts,
"cwd": "/work/proj",
"message": {"role": "assistant", "model": "claude-test-1", "content": blocks}
})
}
fn standard_corpus(w: &World) {
w.write_session(
"alphasess",
&[
serde_json::json!({"type": "ai-title", "aiTitle": "alpha adventures"}),
user_line("u1", "2026-01-01T10:00:00Z", "please fix the zebrafish migration"),
assistant_line(
"a1",
"2026-01-02T10:00:00Z",
serde_json::json!([
{"type": "text", "text": format!("on it — found a leaked key {SECRET} in the env")},
{"type": "tool_use", "id": "t1", "name": "Edit",
"input": {"file_path": "/work/proj/src/widget.rs", "old_string": "a", "new_string": "b"}}
]),
),
serde_json::json!({
"type": "user", "uuid": "u2", "sessionId": "s", "timestamp": "2026-06-01T10:00:00Z",
"message": {"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "edited ok", "is_error": false}
]}
}),
],
);
w.write_session(
"betasess",
&[
user_line("b1", "2026-03-01T09:00:00Z", "how many quokkas in the census"),
assistant_line(
"b2",
"2026-03-01T09:05:00Z",
serde_json::json!([{"type": "text", "text": "seventeen quokkas"}]),
),
user_line("b3", "2026-03-02T09:00:00Z", "thanks"),
assistant_line(
"b4",
"2026-03-02T09:01:00Z",
serde_json::json!([{"type": "text", "text": "anytime"}]),
),
],
);
}
fn pos(hay: &str, needle: &str) -> usize {
hay.find(needle)
.unwrap_or_else(|| panic!("expected {needle:?} in:\n{hay}"))
}
#[test]
fn ls_sort_modes_and_filters() {
let w = World::new("ls");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["ls"]);
assert!(out.contains("2 session(s)"), "{out}");
assert!(pos(&out, "alphases") < pos(&out, "betasess"), "updated order:\n{out}");
assert!(out.contains("alpha adventures"), "{out}");
let (out, _) = w.cv_ok(&["ls", "--sort-by", "created"]);
assert!(pos(&out, "betasess") < pos(&out, "alphases"), "created order:\n{out}");
let (out, _) = w.cv_ok(&["ls", "--sort-by", "messages"]);
assert!(pos(&out, "betasess") < pos(&out, "alphases"), "messages order:\n{out}");
assert!(out.contains("4 msg"), "{out}");
let (out, _) = w.cv_ok(&["ls", "--sort-by", "updated"]);
assert!(pos(&out, "alphases") < pos(&out, "betasess"), "{out}");
let (_, err) = w.cv_fails(&["ls", "--sort-by", "bogus"]);
assert!(err.contains("bogus"), "{err}");
let (out, _) = w.cv_ok(&["ls", "--limit", "1"]);
assert!(out.contains("… 1 more"), "{out}");
let (out, _) = w.cv_ok(&["ls", "--cwd", "/work/proj"]);
assert!(out.contains("2 session(s)"), "{out}");
let (out, _) = w.cv_ok(&["ls", "--cwd", "/nowhere"]);
assert!(out.contains("0 session(s) (of 2 discovered; filtered)"), "{out}");
let (_, err) = w.cv_fails(&["ls", "--harness", "clippy9000"]);
assert!(err.contains("unknown harness"), "{err}");
}
#[test]
fn ls_json_emits_the_same_rows_machine_readably() {
let w = World::new("lsjson");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["ls", "--json"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).expect("stdout must be pure JSON");
assert_eq!(rows.len(), 2, "{out}");
assert_eq!(rows[0]["id"], "alphasess", "updated order:\n{out}");
assert_eq!(rows[0]["harness"], "claude");
assert_eq!(rows[0]["title"], "alpha adventures");
assert_eq!(rows[0]["message_count"], 3);
assert_eq!(rows[0]["cwd"], "/work/proj");
assert!(
rows[0]["created_at"].as_str().unwrap().starts_with("2026-01-01"),
"{out}"
);
assert!(
rows[0]["updated_at"].as_str().unwrap().starts_with("2026-06-01"),
"{out}"
);
assert!(rows[0]["path"].as_str().unwrap().ends_with("alphasess.jsonl"), "{out}");
assert!(rows[1]["title"].is_null(), "{out}");
let real_size = std::fs::metadata(rows[0]["path"].as_str().unwrap()).unwrap().len();
assert_eq!(rows[0]["size_bytes"].as_u64().unwrap(), real_size, "{out}");
assert!(rows[0]["size_bytes"].as_u64().unwrap() > 0, "{out}");
assert!(rows[0].get("git").is_none(), "no --enrich ⇒ no git:\n{out}");
assert!(
rows[0].get("display_title").is_none(),
"no --enrich ⇒ no display_title:\n{out}"
);
let (out, _) = w.cv_ok(&["ls", "--json", "--limit", "1"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).unwrap();
assert_eq!(rows.len(), 1, "{out}");
let (out, _) = w.cv_ok(&["ls", "--json", "--harness", "codex"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).unwrap();
assert!(rows.is_empty(), "{out}");
}
#[test]
fn ls_json_enrich_adds_git_branch_and_synthesized_title() {
let w = World::new("lsjsonenrich");
w.write_session(
"enrichsess",
&[
serde_json::json!({
"type": "user", "uuid": "e1", "sessionId": "s",
"timestamp": "2026-05-01T10:00:00Z", "cwd": "/work/proj", "gitBranch": "feature/enrich",
"message": {"role": "user", "content":
"<system-reminder>be nice</system-reminder>\n\nteach me about capybaras please"}
}),
assistant_line(
"e2",
"2026-05-01T10:01:00Z",
serde_json::json!([{"type": "text", "text": "capybaras are the largest rodents"}]),
),
],
);
let (out, _) = w.cv_ok(&["ls", "--json", "--enrich"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).expect("pure JSON");
assert_eq!(rows.len(), 1, "{out}");
assert!(rows[0]["title"].is_null(), "{out}");
assert_eq!(rows[0]["display_title"], "teach me about capybaras please", "{out}");
assert_eq!(rows[0]["git"]["branch"], "feature/enrich", "{out}");
assert!(rows[0]["size_bytes"].as_u64().unwrap() > 0, "{out}");
}
#[test]
fn search_fallback_messaging_and_index_path() {
let w = World::new("search");
standard_corpus(&w);
let (out, err) = w.cv_ok(&["search", "zebrafish"]);
assert!(err.contains("no index yet"), "want live-scan note, got stderr:\n{err}");
assert!(err.contains("scanning live"), "{err}");
assert!(out.contains("alphases"), "{out}");
assert!(out.to_lowercase().contains("zebrafish"), "snippet expected:\n{out}");
fs::write(w.cv_home.join("index.sqlite"), b"stale").unwrap();
let (_, err) = w.cv_ok(&["search", "zebrafish"]);
assert!(
err.contains("legacy sqlite index no longer used"),
"want legacy-index note, got stderr:\n{err}"
);
fs::remove_file(w.cv_home.join("index.sqlite")).unwrap();
let (out, _) = w.cv_ok(&["index"]);
assert!(out.contains("indexed 2 top-level session(s)"), "{out}");
let (out, err) = w.cv_ok(&["search", "zebrafish"]);
assert!(
!err.contains("scanning live"),
"indexed search must not live-scan:\n{err}"
);
assert!(out.contains("alphases"), "{out}");
let (out, _) = w.cv_ok(&["search", "xyzzyplugh"]);
assert!(out.contains("no matches"), "{out}");
assert!(out.contains("(index"), "{out}");
let (out, _) = w.cv_ok(&["search", "zebrafish", "--harness", "codex"]);
assert!(out.contains("no matches"), "{out}");
}
#[test]
fn search_json_emits_machine_readable_hits() {
let w = World::new("searchjson");
standard_corpus(&w);
let (out, err) = w.cv_ok(&["search", "zebrafish", "--json"]);
assert!(err.contains("scanning live"), "{err}");
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).expect("stdout must be pure JSON");
assert_eq!(rows.len(), 1, "{out}");
assert_eq!(rows[0]["id"], "alphasess", "full id, not the 8-char prefix:\n{out}");
assert_eq!(rows[0]["harness"], "claude");
assert_eq!(rows[0]["title"], "alpha adventures");
assert!(rows[0]["snippet"].as_str().unwrap().contains("zebrafish"), "{out}");
assert!(rows[0]["score"].is_null(), "{out}");
for key in ["agent_id", "parent_id", "workflow"] {
let obj = rows[0].as_object().unwrap();
assert!(obj.contains_key(key), "provenance key {key} must be present:\n{out}");
assert!(obj[key].is_null(), "top-level hit must have null {key}:\n{out}");
}
assert!(
rows[0]["updated_at"].as_str().unwrap().starts_with("2026-06-01"),
"{out}"
);
let (out, _) = w.cv_ok(&["search", "xyzzyplugh", "--json"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).unwrap();
assert!(rows.is_empty(), "{out}");
let (out, err) = w.cv_ok(&["search", "the", "--json", "--limit", "1"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).unwrap();
assert_eq!(rows.len(), 1, "{out}");
assert!(err.contains("stopped at 1"), "{err}");
w.cv_ok(&["index"]);
let (out, err) = w.cv_ok(&["search", "zebrafish", "--json"]);
assert!(!err.contains("scanning live"), "{err}");
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).expect("stdout must be pure JSON");
assert_eq!(rows.len(), 1, "{out}");
assert_eq!(rows[0]["id"], "alphasess", "{out}");
assert!(rows[0]["score"].as_f64().unwrap() > 0.0, "{out}");
let obj = rows[0].as_object().unwrap();
for key in ["agent_id", "parent_id", "workflow"] {
assert!(
obj.contains_key(key) && obj[key].is_null(),
"{key} present+null:\n{out}"
);
}
assert!(
rows[0]["updated_at"].as_str().unwrap().starts_with("2026-06-01"),
"{out}"
);
let (out, _) = w.cv_ok(&["search", "xyzzyplugh", "--json"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).unwrap();
assert!(rows.is_empty(), "{out}");
let (out, _) = w.cv_ok(&["search", "zebrafish", "--json", "--harness", "codex"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(out.trim()).unwrap();
assert!(rows.is_empty(), "{out}");
}
#[test]
fn show_range_windowing() {
let w = World::new("show");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["show", "alphasess"]);
assert!(out.contains("# alpha adventures"), "{out}");
assert!(out.contains("zebrafish migration"), "{out}");
assert!(out.contains("[tool_use Edit t1]"), "{out}");
assert!(out.contains("edited ok"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--range", "1..2"]);
assert!(out.contains("[tool_use Edit t1]"), "{out}");
assert!(!out.contains("zebrafish"), "window must exclude msg 0:\n{out}");
assert!(!out.contains("edited ok"), "window must exclude msg 2:\n{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--range", "2.."]);
assert!(out.contains("edited ok"), "{out}");
assert!(!out.contains("zebrafish"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--range", "..1"]);
assert!(out.contains("zebrafish"), "{out}");
assert!(!out.contains("[tool_use Edit t1]"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--first", "1"]);
assert!(out.contains("zebrafish"), "{out}");
assert!(!out.contains("[tool_use Edit t1]"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--first", "2"]);
assert!(out.contains("zebrafish") && out.contains("[tool_use Edit t1]"), "{out}");
assert!(!out.contains("edited ok"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--last", "1"]);
assert!(out.contains("edited ok"), "{out}");
assert!(!out.contains("zebrafish"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--last", "99"]);
assert!(out.contains("zebrafish") && out.contains("edited ok"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--around", "1", "--context", "0"]);
assert!(out.contains("[tool_use Edit t1]"), "{out}");
assert!(!out.contains("zebrafish"), "{out}");
assert!(!out.contains("edited ok"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--around", "1", "--context", "1"]);
assert!(out.contains("zebrafish") && out.contains("edited ok"), "{out}");
let (_, err) = w.cv_fails(&["show", "alphasess", "--context", "2"]);
assert!(err.contains("--around"), "{err}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--max-bytes", "1"]);
assert!(
out.contains("continue with --range"),
"continuation line expected:\n{out}"
);
assert!(!out.contains("edited ok"), "budget must cut the render short:\n{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--range", "10..20"]);
assert!(out.contains("# alpha adventures"), "{out}");
assert!(!out.contains("──"), "no message blocks expected:\n{out}");
let (_, err) = w.cv_fails(&["show", "alphasess", "--range", "9..3"]);
assert!(err.contains("end (3) is before start (9)"), "{err}");
let (_, err) = w.cv_fails(&["show", "alphasess", "--range", "abc"]);
assert!(err.contains("bad range"), "{err}");
let (_, err) = w.cv_fails(&["show", "alphasess", "--range", "1-2"]);
assert!(
err.contains("A..B") && err.contains("--first N") && err.contains("--last N"),
"`A-B` must point at the new grammar:\n{err}"
);
let (_, err) = w.cv_fails(&["show", "alphasess", "--range", "-1"]);
assert!(
err.contains("--first N") && err.contains("--last N"),
"`-N` must point at the flags:\n{err}"
);
let (_, err) = w.cv_fails(&["show", "alphasess", "--range", "2-"]);
assert!(err.contains("A.."), "`N-` must point at `A..`:\n{err}");
let (_, err) = w.cv_fails(&["show", "alphasess", "--range", "1"]);
assert!(
err.contains("--around 1 --context 0") && err.contains("1..2"),
"a bare index must point at both spellings of one message:\n{err}"
);
for pair in [
["--first", "--last"],
["--first", "--range"],
["--last", "--range"],
["--first", "--around"],
["--last", "--around"],
["--range", "--around"],
] {
let a = if pair[0] == "--range" { "0..1" } else { "1" };
let b = if pair[1] == "--range" { "0..1" } else { "1" };
let args = ["show", "alphasess", pair[0], a, pair[1], b];
let (ok, code, out, err) = w.cv(&args);
assert!(!ok, "two selectors must be refused: cv {args:?}\n{out}");
assert_eq!(code, 2, "clap usage errors exit 2\nstderr:\n{err}");
assert!(
err.contains("cannot be used with"),
"conflict must be named: cv {args:?}\n{err}"
);
}
let (out, _) = w.cv_ok(&["show", "alphasess", "--json", "--range", "1..2"]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["messages"].as_array().unwrap().len(), 1, "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--json", "--first", "2"]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["messages"].as_array().unwrap().len(), 2, "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--json", "--last", "1"]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["messages"].as_array().unwrap().len(), 1, "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--json", "--range", "10..20"]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["messages"].as_array().unwrap().len(), 0, "{out}");
}
#[test]
fn thinking_mode_is_one_word_on_every_emitting_command() {
let w = World::new("think");
standard_corpus(&w);
for cmd in ["port", "splice", "loom", "pack"] {
let (ok, _, out, err) = w.cv(&[cmd, "--help"]);
assert!(ok, "cv {cmd} --help failed:\n{err}");
for mode in ["native", "text", "drop"] {
assert!(
out.contains(mode),
"cv {cmd} --help must offer --thinking {mode}:\n{out}"
);
}
}
let (_, _, _, err) = w.cv(&["port", "alphasess", "--thinking", "sideways"]);
assert!(err.contains("sideways"), "the bad value is quoted back:\n{err}");
let (_, code, _, err) = w.cv(&["prune", "alphasess", "--thinking"]);
assert_eq!(code, 2, "{err}");
assert!(err.contains("--drop-thinking"), "{err}");
}
#[test]
fn export_formats() {
let w = World::new("export");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["export", "alphasess"]);
assert!(out.contains("# alpha adventures"), "{out}");
assert!(out.contains("- harness: claude"), "{out}");
assert!(out.contains("- id: alphasess"), "{out}");
assert!(out.contains("## User"), "{out}");
assert!(out.contains("zebrafish migration"), "{out}");
assert!(out.contains("**🔧 Edit**"), "{out}");
let (out, _) = w.cv_ok(&["export", "alphasess", "--format", "json"]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["open_session"], "0.3", "{out}");
assert!(
out.trim_start().starts_with("{\n \"open_session\""),
"the marker leads the document:\n{out}"
);
assert_eq!(v["id"], "alphasess", "{out}");
assert_eq!(v["harness"], "claude", "{out}");
assert_eq!(v["messages"].as_array().unwrap().len(), 3, "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess", "--json"]);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert!(v.get("open_session").is_none(), "{out}");
assert_eq!(v["id"], "alphasess", "{out}");
let (out, _) = w.cv_ok(&["export", "alphasess", "--format", "html"]);
assert!(out.contains("<html"), "{out}");
assert!(out.contains("zebrafish"), "{out}");
let (_, err) = w.cv_fails(&["export", "alphasess", "--format", "docx"]);
assert!(err.contains("unknown format"), "{err}");
}
#[test]
fn prune_json_emits_machine_readable_report() {
let w = World::new("prunejson");
let big = "x".repeat(4096);
w.write_session(
"prunesrc",
&[
serde_json::json!({
"type": "user", "uuid": "u1", "sessionId": "prunesrc", "timestamp": "2026-01-01T10:00:00Z",
"cwd": "/work/proj",
"message": {"role": "user", "content": "read the big file"}
}),
serde_json::json!({
"type": "assistant", "uuid": "a1", "sessionId": "prunesrc", "timestamp": "2026-01-01T10:01:00Z",
"cwd": "/work/proj",
"message": {"role": "assistant", "model": "claude-test-1", "content": [
{"type": "text", "text": "reading"},
{"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "/big.txt"}}
]}
}),
serde_json::json!({
"type": "user", "uuid": "u2", "sessionId": "prunesrc", "timestamp": "2026-01-01T10:02:00Z",
"message": {"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1", "content": big, "is_error": false}
]}
}),
serde_json::json!({
"type": "assistant", "uuid": "a2", "sessionId": "prunesrc", "timestamp": "2026-01-01T10:03:00Z",
"cwd": "/work/proj",
"message": {"role": "assistant", "model": "claude-test-1", "content": [
{"type": "text", "text": "done"}
]}
}),
],
);
let dir = w.home.join(".claude/projects/-work-proj");
let source_before = fs::read_to_string(dir.join("prunesrc.jsonl")).unwrap();
let (out, _) = w.cv_ok(&[
"prune",
"prunesrc",
"--min-size",
"100",
"--keep-last",
"0",
"--dry-run",
"--json",
]);
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("stdout must be pure JSON");
assert_eq!(v["source_id"], "prunesrc", "{out}");
assert_eq!(v["dry_run"], true, "{out}");
assert!(v["new_id"].is_null(), "unpinned dry-run id must be null:\n{out}");
assert!(v["new_path"].is_null(), "{out}");
assert!(v["sidecar_path"].is_null(), "{out}");
assert!(v["note"].as_str().unwrap().contains("dry run"), "{out}");
assert_eq!(v["snipped_payloads"], 1, "{out}");
assert!(
v["before_bytes"].as_u64().unwrap() > v["after_bytes"].as_u64().unwrap(),
"{out}"
);
assert_eq!(fs::read_dir(&dir).unwrap().count(), 1, "dry run must write nothing");
let (out, _) = w.cv_ok(&[
"prune",
"prunesrc",
"--min-size",
"100",
"--keep-last",
"0",
"--dry-run",
"--to",
"pinnedsess",
"--json",
]);
let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
assert_eq!(v["new_id"], "pinnedsess", "{out}");
assert!(v["new_path"].is_null(), "still nothing written:\n{out}");
let (out, _) = w.cv_ok(&[
"prune",
"prunesrc",
"--min-size",
"100",
"--keep-last",
"0",
"--to",
"prunedsess",
"--json",
]);
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("stdout must be pure JSON");
assert_eq!(v["source_id"], "prunesrc", "{out}");
assert_eq!(v["new_id"], "prunedsess", "{out}");
assert_eq!(v["harness"], "claude", "{out}");
assert_eq!(v["dry_run"], false, "{out}");
assert!(v["note"].is_null(), "{out}");
assert_eq!(v["snipped_payloads"], 1, "{out}");
assert!(v["tokens_freed"].as_u64().unwrap() > 0, "{out}");
let new_path = PathBuf::from(v["new_path"].as_str().unwrap());
assert!(new_path.ends_with("prunedsess.jsonl"), "{out}");
assert!(new_path.exists(), "pruned session must exist at new_path");
let sidecar = PathBuf::from(v["sidecar_path"].as_str().unwrap());
assert!(sidecar.ends_with("prunedsess.flat.jsonl"), "{out}");
assert!(sidecar.exists(), "sidecar must exist at sidecar_path");
assert_eq!(
fs::read_to_string(dir.join("prunesrc.jsonl")).unwrap(),
source_before,
"prune must never modify the source session"
);
let (out, err) = w.cv_ok(&[
"prune",
"prunesrc",
"--min-size",
"100",
"--keep-last",
"0",
"--dry-run",
]);
assert!(out.trim().is_empty(), "non-json prune must keep stdout empty:\n{out}");
assert!(err.contains("pruned"), "{err}");
}
fn keeper_corpus(w: &World) -> PathBuf {
let sid = "aaaaaaaa-0000-4000-8000-000000000001";
let line = |ty: &str, uuid: &str, parent: Option<&str>, message: serde_json::Value| {
serde_json::json!({"type": ty, "sessionId": sid, "uuid": uuid, "parentUuid": parent,
"isSidechain": false, "cwd": "/work/proj", "timestamp": "2026-06-01T10:00:00Z", "message": message})
};
let boundary = |uuid: &str| {
serde_json::json!({"type": "system", "subtype": "compact_boundary", "sessionId": sid, "uuid": uuid,
"parentUuid": null, "isSidechain": false, "compactMetadata": {"trigger": "auto"}})
};
let path = w.write_session(
sid,
&[
line("user", "u0", None, serde_json::json!({"role": "user", "content": "fix the gate"})),
boundary("b1"),
line("assistant", "a1", Some("b1"), serde_json::json!({"role": "assistant", "content": [
{"type": "tool_use", "id": "toolu_c", "name": "Bash",
"input": {"command": "git commit -am 'gate: name the project'"}}]})),
line("user", "r1", Some("a1"), serde_json::json!({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_c", "content": "[main 63cef47] gate: name the project"}]})),
line("assistant", "a2", Some("r1"), serde_json::json!({"role": "assistant", "content": [
{"type": "text", "text": "landed"}]})),
boundary("b2"),
line("user", "u3", Some("b2"), serde_json::json!({"role": "user", "content": "now something else"})),
],
);
let agent = w
.home
.join(".claude/projects/-work-proj")
.join(sid)
.join("subagents/agent-a7f5742c50c0b9654.jsonl");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
let side = |uuid: &str, parent: Option<&str>, ty: &str, message: serde_json::Value| {
serde_json::json!({"type": ty, "sessionId": sid, "uuid": uuid, "parentUuid": parent,
"isSidechain": true, "agentId": "a7f5742c50c0b9654", "cwd": "/work/proj",
"timestamp": "2026-06-01T11:00:00Z", "message": message})
};
let lines = [
side(
"s0",
None,
"user",
serde_json::json!({"role": "user", "content": "review lane"}),
),
side(
"s1",
Some("s0"),
"assistant",
serde_json::json!({"role": "assistant", "content": [
{"type": "tool_use", "id": "toolu_s", "name": "Bash",
"input": {"command": "git commit -qam fix && git rev-parse HEAD"}}]}),
),
side(
"s2",
Some("s1"),
"user",
serde_json::json!({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_s", "content": "9f00ba5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e"}]}),
),
side(
"s3",
Some("s2"),
"assistant",
serde_json::json!({"role": "assistant", "content": [
{"type": "text", "text": "done"}]}),
),
];
let body: String = lines.iter().map(|l| format!("{l}\n")).collect();
fs::write(&agent, body).unwrap();
path
}
fn jsonl(path: &std::path::Path) -> Vec<serde_json::Value> {
fs::read_to_string(path)
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn rewind_derives_the_agent_as_of_a_commit() {
let w = World::new("rewind");
let src = keeper_corpus(&w);
let before = fs::read(&src).unwrap();
let out = w.base.join("rw");
let out_s = out.to_str().unwrap();
let (stdout, err) = w.cv_ok(&[
"rewind",
"aaaaaaaa",
"--at",
"63cef473b771e07a",
"--out",
out_s,
"--json",
]);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stdout must be pure JSON");
assert_eq!(v["source_id"], "aaaaaaaa-0000-4000-8000-000000000001", "{stdout}");
assert_eq!(
(v["start_line"].as_u64(), v["cut_line"].as_u64()),
(Some(2), Some(4)),
"{stdout}"
);
assert_eq!(v["evidence"]["kind"], "created", "{stdout}");
assert_eq!(v["omitted_lines"], 3, "{stdout}");
let new_id = v["new_id"].as_str().unwrap().to_string();
let new_path = PathBuf::from(v["new_path"].as_str().unwrap());
assert_eq!(new_path, out.join(format!("{new_id}.jsonl")));
let got = jsonl(&new_path);
assert_eq!(got.len(), 3);
assert!(got.iter().all(|r| r["sessionId"] == new_id.as_str()), "{got:?}");
assert!(PathBuf::from(v["provenance_path"].as_str().unwrap()).exists());
assert_eq!(
v["resume"],
serde_json::json!(["cd /work/proj", format!("claude --resume {new_id}")])
);
assert!(err.contains("exact: commit created here"), "{err}");
assert!(
err.contains("copy it there first"),
"an --out elsewhere must say where claude looks:\n{err}"
);
assert_eq!(fs::read(&src).unwrap(), before, "rewind must never modify the source");
let (stdout, _) = w.cv_ok(&["rewind", "aaaaaaaa", "--at", "1", "--to", "rw-by-idx", "--json"]);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(v["cut_msg_idx"], 1, "{stdout}");
assert!(w.home.join(".claude/projects/-work-proj/rw-by-idx.jsonl").exists());
assert!(w
.home
.join(".claude/projects/-work-proj/rw-by-idx.rewind.json")
.exists());
let (stdout, _) = w.cv_ok(&["rewind", "aaaaaaaa", "--full", "--dry-run", "--json"]);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
(v["dry_run"].as_bool(), v["start_line"].as_u64()),
(Some(true), Some(1)),
"{stdout}"
);
assert!(v["new_id"].is_null() && v["new_path"].is_null(), "{stdout}");
let (_, err) = w.cv_fails(&["rewind", "aaaaaaaa", "--at", "deadbeef1"]);
assert!(err.contains("deadbeef1") && err.contains("git commit"), "{err}");
let (ok, code, _, err) = w.cv(&["rewind", "aaaaaaaa", "--at", "nope"]);
assert!(!ok && code == 2, "{err}");
let (ok, code, _, err) = w.cv(&["rewind", "aaaaaaaa", "--at", "99"]);
assert!(!ok && code == 1 && err.contains("message(s)"), "{err}");
}
#[test]
fn rewind_extracts_a_subagent_as_a_standalone_session() {
let w = World::new("rewindsub");
keeper_corpus(&w);
let proj = w.home.join(".claude/projects/-work-proj");
let (stdout, err) = w.cv_ok(&[
"rewind",
"agent-a7f5742c",
"--at",
"9f00ba5",
"--to",
"kept-sub",
"--json",
]);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout}\n{err}"));
assert_eq!(v["subagent"], true, "{stdout}");
assert_eq!(
v["source_session_id"], "aaaaaaaa-0000-4000-8000-000000000001",
"{stdout}"
);
assert_eq!(v["cut_line"], 3, "{stdout}");
let got = jsonl(&proj.join("kept-sub.jsonl"));
assert_eq!(got.len(), 3);
assert!(got
.iter()
.all(|r| r["isSidechain"] == false && r.get("agentId").is_none() && r["sessionId"] == "kept-sub"));
assert!(got[0]["parentUuid"].is_null(), "the root chain starts at a null parent");
assert!(err.contains("sub-agent of aaaaaaaa"), "{err}");
let (stdout, _) = w.cv_ok(&["rewind", "aaaaaaaa", "--agent", "a7f57", "--dry-run", "--json"]);
assert!(stdout.contains("\"subagent\": true"), "{stdout}");
let path = proj.join("aaaaaaaa-0000-4000-8000-000000000001/subagents/agent-a7f5742c50c0b9654.jsonl");
let (stdout, _) = w.cv_ok(&["rewind", path.to_str().unwrap(), "--dry-run", "--json"]);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(
(v["subagent"].as_bool(), v["lines_written"].as_u64()),
(Some(true), Some(4)),
"{stdout}"
);
let (out, _) = w.cv_ok(&["show", "kept-sub"]);
assert!(out.contains("review lane"), "{out}");
}
#[test]
fn dataset_emits_parseable_jsonl() {
let w = World::new("dataset");
standard_corpus(&w);
let (out, err) = w.cv_ok(&["dataset"]);
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(
lines.len(),
2,
"one record per session:\nstdout:\n{out}\nstderr:\n{err}"
);
for line in &lines {
let v: serde_json::Value = serde_json::from_str(line).expect("each record is JSON");
assert!(v["messages"].is_array(), "chatml shape: {line}");
}
assert!(err.contains("wrote 2 record(s)"), "{err}");
let (out, _) = w.cv_ok(&["dataset", "--format", "sharegpt", "--redact"]);
assert!(!out.contains(SECRET), "dataset --redact leaked the secret:\n{out}");
for line in out.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).expect("valid JSON");
assert!(v["conversations"].is_array(), "sharegpt shape: {line}");
}
let (_, err) = w.cv_fails(&["dataset", "--format", "parquet"]);
assert!(err.contains("unknown format"), "{err}");
}
#[test]
fn events_and_touched() {
let w = World::new("events");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["events", "alphasess"]);
assert!(out.contains("event(s) in claude:alphases"), "{out}");
assert!(out.contains("file_edit"), "{out}");
assert!(out.contains("/work/proj/src/widget.rs"), "{out}");
let (out, _) = w.cv_ok(&["events", "alphasess", "--kind", "command"]);
assert!(out.contains("no \"command\" events"), "{out}");
let (out, _) = w.cv_ok(&["events", "betasess"]);
assert!(out.contains("no tool events"), "{out}");
let (out, _) = w.cv_ok(&["touched", "/work/proj/src/widget.rs"]);
assert!(out.contains("1 session(s) touched"), "{out}");
assert!(out.contains("alphases"), "{out}");
let (out, _) = w.cv_ok(&["touched", "src/widget.rs", "--edits-only"]);
assert!(out.contains("alphases"), "{out}");
assert!(out.contains("edit(s)"), "{out}");
let (out, _) = w.cv_ok(&["touched", "src/nonexistent.rs"]);
assert!(out.contains("no sessions touched"), "{out}");
let (_, err) = w.cv_fails(&["events", "bogus-id"]);
assert!(err.contains("no session matching"), "{err}");
}
#[test]
fn redact_scrubs_planted_secret() {
let w = World::new("redact");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["show", "alphasess"]);
assert!(out.contains(SECRET), "fixture sanity: secret present in show:\n{out}");
let (out, err) = w.cv_ok(&["redact", "alphasess", "--stats"]);
assert!(!out.contains(SECRET), "redact leaked the secret:\n{out}");
assert!(out.contains("[REDACTED:api_key]"), "{out}");
assert!(err.contains("redacted"), "{err}");
assert!(err.contains("1 api_key"), "{err}");
let (out, _) = w.cv_ok(&["redact", "alphasess", "--format", "json"]);
assert!(!out.contains(SECRET), "redact --format json leaked the secret:\n{out}");
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["id"], "alphasess");
let (_, err) = w.cv_fails(&["redact", "alphasess", "--format", "pdf"]);
assert!(err.contains("unknown format"), "{err}");
}
#[test]
fn error_paths_exit_nonzero_with_stderr() {
let w = World::new("errs");
standard_corpus(&w);
let (_, err) = w.cv_fails(&["show", "nonexistent-id"]);
assert!(err.contains("no session (and no sub-agent) matching"), "{err}");
let (_, err) = w.cv_fails(&["export", "nonexistent-id"]);
assert!(err.contains("no session matching"), "{err}");
let (_, err) = w.cv_fails(&["tree", "nonexistent-id"]);
assert!(err.contains("no session matching"), "{err}");
let (_, err) = w.cv_fails(&["resume", "nonexistent-id"]);
assert!(err.contains("no session matching"), "{err}");
let (_, err) = w.cv_fails(&["diff", "alphasess", "nonexistent-id"]);
assert!(err.contains("no session matching"), "{err}");
let (_, err) = w.cv_fails(&["show", "alphasess", "--harness", "marsrover"]);
assert!(err.contains("unknown harness"), "{err}");
let removed: &[(&[&str], &str)] = &[
(
&["convert", "alphasess", "--to", "marsrover"],
"cv port <id> --harness <harness>",
),
(&["query"], "cv schema"),
(&["recall", "zebrafish"], "cv pack <task>"),
(
&["prune", "alphasess", "--retrieve", "t1"],
"cv cat <session> <tool_use_id>",
),
(&["port", "alphasess", "--to", "codex"], "use `--harness <harness>`"),
(&["port", "alphasess", "--to-dir", "/tmp/nope"], "use `--cwd <dir>`"),
(&["prune", "alphasess", "--thinking"], "use `--drop-thinking`"),
];
for (args, pointer) in removed {
let (ok, code, out, err) = w.cv(args);
assert!(!ok, "cv {args:?} must not still work\nstdout:\n{out}");
assert_eq!(code, 2, "cv {args:?} must exit 2 (caller mistake)\nstderr:\n{err}");
assert!(!err.contains("panicked"), "cv {args:?} panicked:\n{err}");
assert!(err.contains("0.11.0"), "cv {args:?} must say when it went away:\n{err}");
assert!(err.contains(pointer), "cv {args:?} must point at {pointer:?}:\n{err}");
}
let (_, err) = w.cv_fails(&["port", "alphasess", "--harness", "marsrover"]);
assert!(err.contains("unknown"), "{err}");
let (_, err) = w.cv_fails(&["splice", "alphasess:1-2"]);
assert!(err.contains("bad spec") && err.contains("<id>:A..B"), "{err}");
let (_, err) = w.cv_fails(&["splice", "alphasess:zz..3"]);
assert!(err.contains("bad spec"), "{err}");
let (ok, code, out, err) = w.cv(&["events", ""]);
assert!(!ok, "an ambiguous id must not be silently resolved:\n{out}");
assert_eq!(code, 2, "{err}");
assert!(err.contains("ambiguous id"), "{err}");
assert!(err.contains("2 candidates"), "{err}");
assert!(
err.contains("claude:alphasess") && err.contains("claude:betasess"),
"candidates must be listed as harness:full-id:\n{err}"
);
w.cv_ok(&["events", "claude:alphasess"]);
}
#[test]
fn blame_outside_repo_and_missing_file() {
let w = World::new("blame");
standard_corpus(&w);
let (_ok, code, out, err) = w.cv(&["blame", "/not/a/file"]);
assert_ne!(code, -1, "cv blame died by signal\nstderr:\n{err}");
assert!(!err.contains("panicked"), "panic in blame:\n{err}");
assert!(
out.contains("not inside a git repository") || !err.trim().is_empty(),
"blame must explain itself\nstdout:\n{out}\nstderr:\n{err}"
);
}
#[test]
fn diff_and_tree_render() {
let w = World::new("difftree");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["diff", "alphasess", "betasess"]);
assert!(out.contains("only-in-A"), "{out}");
assert!(out.contains("0 shared"), "different sessions share no prefix:\n{out}");
let (out, _) = w.cv_ok(&["tree", "alphasess"]);
assert!(out.contains("# alpha adventures"), "{out}");
assert!(out.contains("3 msg"), "{out}");
}
#[test]
fn stats_and_timeline() {
let w = World::new("stats");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["stats"]);
assert!(out.contains("2 session(s) · 7 message(s)"), "{out}");
assert!(out.contains("claude"), "{out}");
let (out, _) = w.cv_ok(&["timeline"]);
assert!(out.contains("2 session(s)"), "{out}");
assert!(pos(&out, "betasess") < pos(&out, "alphases"), "{out}");
}
#[test]
fn stats_tokens_totals_usage_per_model() {
let w = World::new("stats-tokens");
let reply = |uuid: &str, id: &str, model: &str| {
serde_json::json!({
"type": "assistant", "uuid": uuid, "sessionId": "s", "timestamp": "2026-03-01T09:05:00Z",
"message": {"id": id, "role": "assistant", "model": model,
"content": [{"type": "text", "text": "ok"}],
"usage": {"input_tokens": 5, "cache_read_input_tokens": 2_000_000,
"cache_creation_input_tokens": 1_000, "output_tokens": 300}}
})
};
w.write_session(
"toksess",
&[
user_line("u1", "2026-03-01T09:00:00Z", "go"),
reply("a1", "msg_1", "claude-opus-5-5"),
reply("a2", "msg_1", "claude-opus-5-5"),
reply("a3", "msg_2", "claude-fable-5-1"),
],
);
let (out, _) = w.cv_ok(&["stats", "--tokens"]);
assert!(out.contains("1 repeated usage record(s) skipped"), "{out}");
assert!(out.contains("claude-opus-5-5"), "{out}");
assert!(out.contains("claude-fable-5-1"), "{out}");
assert!(out.contains("4.0M"), "{out}");
let (json, _) = w.cv_ok(&["stats", "--tokens", "--json"]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["tokens"]["total"]["calls"], 2);
assert_eq!(v["tokens"]["total"]["total"], 4_002_610);
assert_eq!(v["tokens"]["total"]["uncached"], 2_610);
assert_eq!(v["tokens"]["duplicates"], 1);
let (json, _) = w.cv_ok(&["stats", "--json"]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
v.get("tokens").is_none(),
"without --tokens the payload stays catalog-only"
);
}
#[test]
fn id_prefix_resolution() {
let w = World::new("prefix");
standard_corpus(&w);
let (out, _) = w.cv_ok(&["show", "alpha"]);
assert!(out.contains("# alpha adventures"), "{out}");
let (out, _) = w.cv_ok(&["show", "beta", "--harness", "claude"]);
assert!(out.contains("quokkas"), "{out}");
}
#[test]
fn extra_claude_roots_are_listed_and_read() {
let w = World::new("roots");
standard_corpus(&w);
let seat_proj = w.home.join("seats/bonsai/claude/projects/-work-proj");
fs::create_dir_all(&seat_proj).unwrap();
let seat_line = user_line("s1", "2026-07-01T10:00:00Z", "the seat asks about narwhals");
fs::write(seat_proj.join("seatsess.jsonl"), format!("{seat_line}\n")).unwrap();
let env_proj = w.base.join("elsewhere/projects/-work-proj");
fs::create_dir_all(&env_proj).unwrap();
let env_line = user_line("e1", "2026-07-02T10:00:00Z", "the env root asks about axolotls");
fs::write(env_proj.join("envsess.jsonl"), format!("{env_line}\n")).unwrap();
w.cv_fails(&["show", "seatsess"]);
fs::write(w.cv_home.join("claude-roots"), "# agent seats\n~/seats/*/claude\n").unwrap();
let (out, _) = w.cv_ok(&["show", "seatsess"]);
assert!(out.contains("narwhals"), "{out}");
let (out, _) = w.cv_ok(&["show", "alphasess"]);
assert!(out.contains("zebrafish"), "the default root still works:\n{out}");
let (out, _) = w.cv_ok(&["ls"]);
assert!(out.contains("3 session(s)"), "{out}");
let (out, _) = w.cv_ok(&["search", "narwhals"]);
assert!(out.contains("seatsess"), "{out}");
let roots = w.base.join("elsewhere");
let (ok, _, out, err) = w.cv_env(
&["show", "envsess"],
&[("CLUSTERVISION_CLAUDE_ROOTS", roots.to_str().unwrap())],
);
assert!(ok, "stdout:\n{out}\nstderr:\n{err}");
assert!(out.contains("axolotls"), "{out}");
}
fn opened_task_id(stdout: &str) -> String {
stdout
.lines()
.map(str::trim)
.find(|l| l.len() == 36 && l.chars().filter(|c| *c == '-').count() == 4)
.unwrap_or_else(|| panic!("no task id in open output:\n{stdout}"))
.to_string()
}
#[test]
fn task_identity_resolution_order() {
let w = World::new("task-ident");
let (out, _) = w.cv_ok(&["task", "open", "first task"]);
let id = opened_task_id(&out);
let (json, _) = w.cv_ok(&["task", "show", &id, "--json"]);
assert!(json.contains(r#""opened_by": "cv""#), "{json}");
let (ok, _, _, err) = w.cv_env(&["task", "claim", &id], &[]);
assert!(!ok, "claim without identity must fail");
assert!(
err.contains("set CV_ENDPOINT or pass --from"),
"error names the cure:\n{err}"
);
let (ok, code, out, err) = w.cv_env(&["task", "claim", &id], &[("CV_ENDPOINT", "agent:demo")]);
assert!(ok, "claim with CV_ENDPOINT: exit {code}\n{out}\n{err}");
let (json, _) = w.cv_ok(&["task", "show", &id, "--json"]);
assert!(json.contains(r#""assignee": "agent:demo""#), "{json}");
let (ok, ..) = w.cv_env(
&["task", "release", &id, "--from", "agent:explicit"],
&[("CV_ENDPOINT", "agent:demo")],
);
assert!(ok);
let (json, _) = w.cv_ok(&["task", "show", &id, "--json", "--events"]);
assert!(
json.contains(r#""by": "agent:explicit""#),
"explicit --from wins:\n{json}"
);
let (ok, _, _, err) = w.cv_env(&["task", "claim", &id], &[("CV_ENDPOINT", " ")]);
assert!(!ok && err.contains("CV_ENDPOINT"), "{err}");
}
#[test]
fn task_surfaces_sanitize_and_age() {
let w = World::new("task-ansi");
let bomb = "evil\u{1b}]0;pwned\u{7}title \u{1b}[31mred\nline";
let (out, _) = w.cv_ok(&["task", "open", bomb]);
let id = opened_task_id(&out);
let (out, _) = w.cv_ok(&["task", "list"]);
assert!(!out.contains('\u{1b}'), "list leaks ESC:\n{out:?}");
assert!(out.contains("eviltitle red line"), "stripped title renders:\n{out}");
assert!(out.contains("0s") || out.contains("1s"), "age column present:\n{out}");
let (out, _) = w.cv_ok(&["task", "show", &id]);
assert!(!out.contains('\u{1b}'), "show leaks ESC:\n{out:?}");
let (ok, ..) = w.cv_env(&["task", "claim", &id], &[("CV_ENDPOINT", "agent:demo")]);
assert!(ok);
let (out, _) = w.cv_ok(&["task", "inbox", "agent:demo"]);
assert!(!out.contains('\u{1b}'), "inbox leaks ESC:\n{out:?}");
assert!(out.contains("claimed work (1):"), "inbox groups by reason:\n{out}");
let (out, _) = w.cv_ok(&["task", "debt"]);
assert!(!out.contains('\u{1b}'), "debt leaks ESC:\n{out:?}");
}
#[test]
fn task_propose_warns_on_branch_collision() {
let w = World::new("task-collide");
let repo = w.base.join("repo");
fs::create_dir_all(&repo).unwrap();
let git = |args: &[&str]| {
let out = Command::new("git")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.arg("-C")
.arg(&repo)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "t"]);
fs::write(repo.join("a.txt"), "a").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "base"]);
git(&["checkout", "-q", "-b", "task/shared"]);
fs::write(repo.join("b.txt"), "b").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "feat"]);
let repo_s = repo.to_str().unwrap();
let (out, _) = w.cv_ok(&["task", "open", "first", "--repo", repo_s]);
let t1 = opened_task_id(&out);
let (out, _) = w.cv_ok(&["task", "open", "second", "--repo", repo_s]);
let t2 = opened_task_id(&out);
let (ok, _, _, err) = w.cv_env(
&["task", "propose", &t1, "--branch", "task/shared", "--upstream", "main"],
&[("CV_ENDPOINT", "agent:a")],
);
assert!(ok, "{err}");
assert!(!err.contains("collision"), "first propose must not warn:\n{err}");
let (ok, code, out, err) = w.cv_env(
&["task", "propose", &t2, "--branch", "task/shared", "--upstream", "main"],
&[("CV_ENDPOINT", "agent:b")],
);
assert!(ok, "collision is a warning, not a block: exit {code}\n{out}\n{err}");
assert!(
err.contains("WARNING") && err.contains("already carried by task") && err.contains(&t1[..8]),
"warning names the carrier:\n{err}"
);
assert!(err.contains("collision"), "{err}");
}
#[test]
fn task_receipts_and_stats_end_to_end() {
let w = World::new("task-receipts");
let repo = w.base.join("repo");
fs::create_dir_all(&repo).unwrap();
let git = |args: &[&str]| {
let out = Command::new("git")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.arg("-C")
.arg(&repo)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-q", "-b", "main"]);
git(&["config", "user.email", "t@example.com"]);
git(&["config", "user.name", "t"]);
fs::write(repo.join("a.txt"), "a").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "base"]);
git(&["checkout", "-q", "-b", "task/receipts"]);
fs::write(repo.join("b.txt"), "b").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "feat"]);
let repo_s = repo.to_str().unwrap();
w.write_session(
"reviewsess",
&[
user_line("u1", "2026-07-16T10:00:00Z", "review task/receipts please"),
assistant_line(
"a1",
"2026-07-16T10:01:00Z",
serde_json::json!([
{"type": "tool_use", "id": "t1", "name": "Bash",
"input": {"command": "git diff main...task/receipts"}},
{"type": "tool_use", "id": "t2", "name": "Bash",
"input": {"command": "cargo test --workspace"}},
]),
),
],
);
let (out, _) = w.cv_ok(&["task", "open", "with receipts", "--repo", repo_s]);
let t1 = opened_task_id(&out);
let (ok, ..) = w.cv_env(&["task", "claim", &t1], &[("CV_ENDPOINT", "agent:author")]);
assert!(ok);
let (ok, _, _, err) = w.cv_env(
&[
"task",
"propose",
&t1,
"--branch",
"task/receipts",
"--upstream",
"main",
],
&[("CV_ENDPOINT", "agent:author")],
);
assert!(ok, "{err}");
let (ok, code, out, err) = w.cv_env(
&[
"task",
"pass",
&t1,
"--session",
"reviewsess",
"--from",
"agent:reviewer",
],
&[],
);
assert!(ok, "pass with session exited {code}\n{out}\n{err}");
assert!(
!err.contains("no observable contact") && !err.contains("receipts not observed"),
"contact was in the transcript, no receipts warning expected:\n{err}"
);
let (out, _) = w.cv_ok(&["task", "show", &t1]);
assert!(
out.contains("receipts (pass): saw change ✓, ran checks ✓, 1 turns"),
"receipts line missing:\n{out}"
);
let (out, _) = w.cv_ok(&["task", "open", "no receipts", "--repo", repo_s]);
let t2 = opened_task_id(&out);
let (ok, ..) = w.cv_env(&["task", "claim", &t2], &[("CV_ENDPOINT", "agent:author")]);
assert!(ok);
let (ok, _, _, err) = w.cv_env(
&[
"task",
"propose",
&t2,
"--branch",
"task/receipts",
"--upstream",
"main",
],
&[("CV_ENDPOINT", "agent:author")],
);
assert!(ok, "{err}");
let (ok, code, _, err) = w.cv_env(&["task", "pass", &t2, "--from", "agent:reviewer"], &[]);
assert!(ok, "session-less pass must not be blocked: exit {code}\n{err}");
assert!(
err.contains("no reviewer session given: review receipts not observed"),
"missing receipts warning:\n{err}"
);
let (out, _) = w.cv_ok(&["task", "show", &t2]);
assert!(
!out.contains("receipts (pass)"),
"no receipts recorded → no line:\n{out}"
);
let (out, err) = w.cv_ok(&["task", "stats"]);
assert!(out.contains("agent:author"), "{out}");
assert!(out.contains("agent:reviewer"), "{out}");
assert!(
out.contains("computed from observed events only; landed = git-verified"),
"{out}"
);
assert!(
out.contains("verified: NEVER"),
"no verify pass ran in this world:\n{out}"
);
assert!(
err.contains("NEVER been verified"),
"freshness warning on stderr:\n{err}"
);
let reviewer_row = out.lines().find(|l| l.contains("agent:reviewer")).unwrap();
assert!(reviewer_row.contains("2/0"), "two passes, zero refutes: {reviewer_row}");
let (json, _) = w.cv_ok(&["task", "stats", "--json"]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let reviewers = v["reviewers"].as_array().unwrap();
assert_eq!(reviewers.len(), 1);
assert_eq!(reviewers[0]["no_receipts_passes"], 1);
assert_eq!(reviewers[0]["no_contact_passes"], 0);
assert_eq!(reviewers[0]["passes"], 2);
}
#[test]
fn board_read_sanitizes_and_from_defaults_to_cv_endpoint() {
let w = World::new("board-ansi");
let (ok, ..) = w.cv_env(
&["board", "post", "general", "hi \u{1b}[31mthere\u{1b}]0;x\u{7}!"],
&[("CV_ENDPOINT", "agent:board\u{1b}[0m")],
);
assert!(ok);
let (out, _) = w.cv_ok(&["board", "read", "general"]);
assert!(!out.contains('\u{1b}'), "board read leaks ESC:\n{out:?}");
assert!(out.contains("hi there!"), "{out}");
assert!(out.contains("agent:board"), "CV_ENDPOINT is the default sender:\n{out}");
}
#[test]
fn task_open_relations_tags_and_file_bodies() {
let w = World::new("task-ergo");
let brief = w.base.join("brief.md");
fs::write(
&brief,
"# The brief\n\nA body that is a document, not a shell string.\n",
)
.unwrap();
let (out, _) = w.cv_ok(&[
"task",
"open",
"FOUNDATION: the thing others wait on",
"--tags",
"lean,deploy",
]);
let a = opened_task_id(&out);
let (out, _) = w.cv_ok(&[
"task",
"open",
"DEPENDENT: needs the foundation",
"--body-file",
brief.to_str().unwrap(),
"--blocked-by",
&a,
"--tags",
"lean",
]);
let b = opened_task_id(&out);
let (out, _) = w.cv_ok(&["task", "open", "UPSTREAM: blocks the foundation", "--blocks", &a]);
let c = opened_task_id(&out);
let (json, _) = w.cv_ok(&["task", "show", &b, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(t["body"].as_str().unwrap().contains("not a shell string"), "{json}");
assert_eq!(t["tags"], serde_json::json!(["lean"]), "{json}");
assert_eq!(t["blocked_by"], serde_json::json!([a]), "{json}");
let (json, _) = w.cv_ok(&["task", "show", &a, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["blocked_by"], serde_json::json!([c]), "{json}");
assert_eq!(t["tags"], serde_json::json!(["lean", "deploy"]), "{json}");
let (out, _) = w.cv_ok(&["task", "show", &a]);
assert!(out.contains("tags: lean, deploy"), "{out}");
assert!(
out.contains("blocked by: ") && out.contains("[open] UPSTREAM: blocks the foundation"),
"{out}"
);
assert!(out.contains("⊘ BLOCKED"), "{out}");
assert!(
out.contains("blocks: ") && out.contains("DEPENDENT: needs the foundation"),
"{out}"
);
let (out, _) = w.cv_ok(&["task", "list"]);
let blocked_rows = |out: &str| {
out.lines()
.filter(|l| l.starts_with(|c: char| c.is_ascii_hexdigit()) && l.contains("⊘ "))
.count()
};
assert_eq!(blocked_rows(&out), 2, "A and B are blocked:\n{out}");
assert!(out.contains("⊘ = blocked"), "{out}");
let (out, _) = w.cv_ok(&["task", "list", "--tag", "deploy"]);
assert!(out.contains("FOUNDATION") && !out.contains("DEPENDENT"), "{out}");
let (out, _) = w.cv_ok(&["task", "list"]);
for line in out.lines().filter(|l| l.starts_with(|c: char| c.is_ascii_hexdigit())) {
let shown = line.split_whitespace().next().unwrap();
let (ok, _, _, err) = w.cv(&["task", "show", shown]);
assert!(ok, "the prefix `list` prints must resolve in `show`: {shown}\n{err}");
}
w.cv_ok(&["task", "done", &c, "--observed", "merged"]);
let (out, _) = w.cv_ok(&["task", "show", &a]);
assert!(!out.contains("⊘ BLOCKED"), "{out}");
let (out, _) = w.cv_ok(&["task", "list"]);
assert_eq!(blocked_rows(&out), 1, "only B stays blocked:\n{out}");
let (out, _) = w.cv_ok(&["task", "list", "--tsv"]);
assert_eq!(out.lines().count(), 2, "{out}");
let cells: Vec<&str> = out.lines().find(|l| l.starts_with(&b)).unwrap().split('\t').collect();
assert_eq!(cells.len(), 7, "{cells:?}");
assert_eq!(cells[1], "open");
assert_eq!(cells[5], "DEPENDENT: needs the foundation");
assert_eq!(cells[6], a);
let (out, _) = w.cv_ok(&["task", "list", "--wide"]);
assert!(
out.contains("#lean · blocked by ") && out.contains("[open] · # The brief"),
"{out}"
);
let note = w.base.join("note.txt");
fs::write(¬e, "progress from a file").unwrap();
w.cv_ok(&["task", "note", &b, "--file", note.to_str().unwrap()]);
let (out, _) = w.cv_ok(&["task", "show", &b]);
assert!(out.contains("progress from a file"), "{out}");
let (ok, ..) = w.cv(&["task", "note", &b]);
assert!(!ok, "note needs text or --file");
w.cv_ok(&["task", "tag", &b, "deploy"]);
let (json, _) = w.cv_ok(&["task", "show", &b, "--json"]);
assert!(json.contains("\"deploy\""), "{json}");
let (ok, _, _, err) = w.cv(&["task", "block", &b, "--by", &b]);
assert!(!ok && err.contains("cannot block itself"), "{err}");
let (ok, _, _, err) = w.cv(&["task", "open", "typo", "--blocked-by", "ffffffff"]);
assert!(
!ok && err.contains("no task matches"),
"relations resolve before the open:\n{err}"
);
let (out, _) = w.cv_ok(&["task", "list", "--all"]);
assert!(!out.contains("typo"), "a refused open leaves nothing behind:\n{out}");
}
#[test]
fn task_inbox_groups_decisions_first() {
let w = World::new("task-inbox");
let d = "01a0f000-0000-7000-8000-00000000000a".to_string();
w.plant_legacy_tag_decision(&d, "pick the enrollment rate", "ember");
w.cv_ok(&["task", "open", "write the docs", "--assignee", "ember"]);
let (out, _) = w.cv_ok(&["task", "open", "the claimed one"]);
let c = opened_task_id(&out);
let (ok, ..) = w.cv_env(&["task", "claim", &c], &[("CV_ENDPOINT", "ember")]);
assert!(ok);
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
let dec = out.find("decisions owed (1):").expect(&out);
let assigned = out.find("assigned actions (1):").expect(&out);
let claimed = out.find("claimed work (1):").expect(&out);
assert!(dec < assigned && assigned < claimed, "group order:\n{out}");
assert!(out.contains("pick the enrollment rate"), "{out}");
let (json, _) = w.cv_ok(&["task", "inbox", "ember", "--json"]);
assert!(json.contains("\"decision_owed\""), "{json}");
assert!(json.contains(&d), "{json}");
let (out, _) = w.cv_ok(&["task", "inbox", "nobody"]);
assert!(out.contains("(inbox empty for nobody)"), "{out}");
}
#[test]
fn task_sweep_names_merged_branches_and_missing_issue_paths_without_closing() {
let w = World::new("task-sweep");
let repo = w.base.join("repo");
fs::create_dir_all(&repo).unwrap();
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(&repo)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.env("HOME", &w.home)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-q", "-b", "main"]);
fs::write(repo.join("a.txt"), "a").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "init"]);
git(&["checkout", "-q", "-b", "k-ran"]);
fs::write(repo.join("b.txt"), "b").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "work"]);
git(&["checkout", "-q", "main"]);
git(&["merge", "-q", "--no-ff", "-m", "merge", "k-ran"]);
git(&["checkout", "-q", "-b", "p3b1"]);
fs::write(repo.join("c.txt"), "c").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "unmerged work"]);
git(&["checkout", "-q", "main"]);
fs::write(repo.join("plan.md"), "plan").unwrap();
let r = repo.to_str().unwrap();
let (out, _) = w.cv_ok(&[
"task",
"open",
"K-RAN: run claim",
"--repo",
r,
"--body",
"one commit on branch `k-ran`, not pushed",
]);
let merged = opened_task_id(&out);
w.cv_ok(&[
"task",
"open",
"P3B1: receiver",
"--repo",
r,
"--body",
"work is on `p3b1`",
]);
w.cv_ok(&["task", "open", "PLAN: keep", "--repo", r, "--issue", "plan.md"]);
let (out, _) = w.cv_ok(&[
"task",
"open",
"GONE: issue file deleted",
"--repo",
r,
"--issue",
"docs/gone.md",
]);
let gone = opened_task_id(&out);
w.cv_ok(&[
"task",
"open",
"WORDS: a body that says fix and main",
"--repo",
r,
"--body",
"fix main",
]);
w.cv_ok(&[
"task",
"open",
"OTHER REPO: names k-ran but lives elsewhere",
"--repo",
w.base.to_str().unwrap(),
"--body",
"k-ran",
]);
let (out, _) = w.cv_ok(&["task", "sweep", "--repo", r]);
assert!(out.contains("2 candidate(s)"), "{out}");
assert!(
out.contains("K-RAN: run claim") && out.contains("names branch `k-ran`, merged into main"),
"{out}"
);
assert!(
out.contains("GONE: issue file deleted") && out.contains("issue path no longer exists"),
"{out}"
);
assert!(
!out.contains("P3B1") && !out.contains("PLAN:") && !out.contains("WORDS") && !out.contains("OTHER REPO"),
"{out}"
);
assert!(out.contains("nothing was closed"), "{out}");
let (json, _) = w.cv_ok(&["task", "show", &merged, "--json"]);
assert!(json.contains("\"state\": \"open\""), "{json}");
let (json, _) = w.cv_ok(&["task", "sweep", "--repo", r, "--json"]);
let rows: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert_eq!(rows.len(), 2);
assert!(rows.iter().any(|x| x["task_id"] == gone));
}
fn normalize(s: &str) -> String {
let s = regex::Regex::new(r"\b[0-9a-f]{8}-[0-9a-f]{1,4}(?:-[0-9a-f]{1,4}){0,3}(?:-[0-9a-f]{1,12})?\b")
.unwrap()
.replace_all(s, "<id>");
let s = regex::Regex::new(r"\b\d{4}-\d\d-\d\d \d\d:\d\d\b")
.unwrap()
.replace_all(&s, "<ts>");
let s = regex::Regex::new(r"\b\d\d-\d\d \d\d:\d\d\b")
.unwrap()
.replace_all(&s, "<ts>");
regex::Regex::new(r"\b\d+[smhdw] ago\b")
.unwrap()
.replace_all(&s, "<age> ago")
.into_owned()
}
#[test]
fn task_decide_resolve_round_trip() {
let w = World::new("decide");
let (out, _) = w.cv_ok(&[
"task",
"decide",
"K-PORTAL: who births the guest cell",
"--for",
"ember",
"--default",
"the concierge births it",
"--option",
"the receiver allocates it",
"--by",
"3d",
"--body",
"default = the concierge; alternative = the receiver (then F pays).",
]);
let d = opened_task_id(&out);
assert!(out.contains("posed for ember"), "{out}");
let (out, _) = w.cv_ok(&["task", "show", &d]);
assert!(out.contains("] decision"), "kind on the header line:\n{out}");
assert!(out.contains("default: the concierge births it"), "{out}");
assert!(out.contains("option: the receiver allocates it"), "{out}");
assert!(
out.contains("(in 2d)") || out.contains("(in 3d)"),
"deadline phrase:\n{out}"
);
assert!(
out.contains("resolve: cv task resolve") && out.contains("--from ember"),
"{out}"
);
assert!(out.contains("tags: decision"), "{out}");
let (json, _) = w.cv_ok(&["task", "show", &d, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["decision"]["default"], "the concierge births it");
assert_eq!(t["decision"]["options"].as_array().unwrap().len(), 2);
assert!(t["decision"]["deadline"].is_string());
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(out.starts_with("decisions owed (1):"), "{out}");
assert!(
out.contains("⇒ default: the concierge births it · alt: the receiver allocates it · by "),
"{out}"
);
assert!(
out.contains("resolve: cv task resolve <id> --accept-default --from ember"),
"{out}"
);
let (json, _) = w.cv_ok(&["task", "inbox", "ember", "--json"]);
assert!(
json.contains("\"decision_owed\"") && json.contains("\"default\": \"the concierge births it\""),
"{json}"
);
let (_, err) = w.cv_fails(&["task", "done", &d]);
assert!(err.contains("answer it with resolve"), "{err}");
let (_, err) = w.cv_fails(&["task", "resolve", &d, "--choice", "burn it", "--from", "ember"]);
assert!(
err.contains("not one of the posed options") && err.contains("--choice \"the receiver allocates it\""),
"{err}"
);
let (_, err) = w.cv_fails(&[
"task",
"resolve",
&d,
"--choice",
"THE RECEIVER allocates it",
"--from",
"ember",
]);
assert!(
err.contains("did you mean --choice \"the receiver allocates it\""),
"{err}"
);
let (_, err) = w.cv_fails(&["task", "resolve", &d, "--accept-default"]);
assert!(
err.contains("records WHO decided")
&& err.contains("--accept-default --from ember")
&& err.contains("export CV_ENDPOINT=ember"),
"the cure names the owner:\n{err}"
);
let (_, err) = w.cv_fails(&["task", "resolve", &d]);
assert!(err.contains("--choice") || err.contains("required"), "{err}");
let (out, _) = w.cv_ok(&["task", "resolve", &d, "--accept-default", "--from", "ember"]);
assert!(
out.contains("resolved") && out.contains("→ the concierge births it (by ember)"),
"{out}"
);
let (out, _) = w.cv_ok(&["task", "show", &d]);
assert!(out.contains("[resolved]"), "{out}");
assert!(
out.contains("resolved: the concierge births it — by ember,") && out.contains("(the default)"),
"{out}"
);
let (json, _) = w.cv_ok(&["task", "show", &d, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["state"], "resolved");
assert_eq!(t["decision"]["resolution"]["accepted_default"], true);
assert_eq!(t["decision"]["resolution"]["by"], "ember");
let (out, _) = w.cv_ok(&["task", "list"]);
assert!(!out.contains("K-PORTAL"), "{out}");
let (out, _) = w.cv_ok(&["task", "list", "--state", "resolved"]);
assert!(out.contains("K-PORTAL"), "{out}");
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(out.contains("(inbox empty for ember)"), "{out}");
let (_, err) = w.cv_fails(&["task", "resolve", &d, "--accept-default", "--from", "ember"]);
assert!(err.contains("resolved") && err.contains("cannot apply"), "{err}");
let (out, _) = w.cv_ok(&[
"task",
"decide",
"Q2",
"--for",
"ember",
"--default",
"a",
"--option",
"b",
]);
let d2 = opened_task_id(&out);
let (ok, _, _, err) = w.cv_full(
&["task", "resolve", &d2, "--choice", "b", "--note", "-"],
&[("CV_ENDPOINT", "ember")],
Some("because b\n"),
);
assert!(ok, "{err}");
let (json, _) = w.cv_ok(&["task", "show", &d2, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["decision"]["resolution"]["choice"], "b");
assert_eq!(t["decision"]["resolution"]["accepted_default"], false);
assert_eq!(t["decision"]["resolution"]["note"], "because b");
w.cv_ok(&["task", "open", "plain work", "--assignee", "ember"]);
let (out, _) = w.cv_ok(&["task", "list", "--all", "--decisions"]);
assert!(out.contains("Q2") && !out.contains("plain work"), "{out}");
let (out, _) = w.cv_ok(&["task", "list", "--actions"]);
assert!(!out.contains("Q2") && out.contains("plain work"), "{out}");
}
#[test]
fn task_inbox_orders_decisions_first_and_renders_markdown() {
let w = World::new("inbox-md");
let (out, _) = w.cv_ok(&["task", "open", "the claimed one", "--body", "claimed body"]);
let c = opened_task_id(&out);
assert!(w.cv_env(&["task", "claim", &c], &[("CV_ENDPOINT", "ember")]).0);
let (out, _) = w.cv_ok(&[
"task",
"open",
"write the docs",
"--assignee",
"ember",
"--body",
"First line of the action.\n\nSecond paragraph.",
]);
let a = opened_task_id(&out);
w.cv_ok(&[
"task",
"note",
&a,
"a note with two lines\nsecond line hidden in md",
"--from",
"orchestrator:x",
]);
let (out, _) = w.cv_ok(&[
"task",
"decide",
"P6: WHO MAY REFILL a purse",
"--for",
"ember",
"--default",
"keep open",
"--option",
"per-refill consent",
"--body",
"today anyone may fund any purse.",
"--from",
"orchestrator:x",
]);
let d = opened_task_id(&out);
w.plant_legacy_tag_decision(
"01a0f000-0000-7000-8000-00000000000b",
"legacy decision by tag",
"ember",
);
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
let dec = out.find("decisions owed (2):").expect(&out);
let assigned = out.find("assigned actions (1):").expect(&out);
let claimed = out.find("claimed work (1):").expect(&out);
assert!(dec < assigned && assigned < claimed, "group order:\n{out}");
assert!(
out.find("P6: WHO MAY REFILL").unwrap() < out.find("legacy decision by tag").unwrap(),
"{out}"
);
let (md, _) = w.cv_ok(&["task", "inbox", "ember", "--md"]);
let got = normalize(&md);
let expected = "\
# Inbox for ember — <ts>
2 decision(s) owed · 1 assigned action(s) · 1 claimed · 0 review(s) · 0 unlanded · 3 unread
## Decisions owed (2)
### 1. P6: WHO MAY REFILL a purse
`<id>` · open · asked <age> ago by orchestrator:x · **unread** (last: orchestrator:x)
- **default:** keep open
- alternative: per-refill consent
- resolve: `cv task resolve <id> --accept-default --from ember`
today anyone may fund any purse.
### 2. legacy decision by tag
`<id>` · open · waiting <age> ago by cv · **unread** (last: cv)
## Assigned actions (1)
### 3. write the docs
`<id>` · open · waiting <age> ago by cv · **unread** (last: orchestrator:x)
First line of the action.
Second paragraph.
notes (1):
- _orchestrator:x, <ts>:_ a note with two lines
## Claimed work (1)
### 4. the claimed one
`<id>` · claimed · waiting <age> ago by cv
claimed body
";
assert_eq!(got, expected, "--md golden (normalized):\n{md}");
let _ = d;
}
#[test]
fn task_split_turns_decide_notes_into_decisions() {
let w = World::new("split");
let (out, _) = w.cv_ok(&[
"task",
"open",
"EMBER: everything only ember can do",
"--assignee",
"ember",
"--repo",
w.base.to_str().unwrap(),
]);
let parent = opened_task_id(&out);
let notes = [
"DECIDE: move the build base from /tank (slow) to NVMe? Default if silent: keep /tank.",
"PUSH also ~/dev/cv: one unsigned commit.",
"DECIDE (K-PORTAL, default stands if silent): who births a guest cell — default = the concierge births it; alternative = the receiver allocates it (then F pays).",
"DECIDE (DEOS.md §5, defaults stand if silent; the doc has the full argument): (1) quotes: REFERENCED by default; (2) web face: LOCAL ONLY.",
"JOIN-SOLANA works. DECIDE: one enrollment per tip, raise the floor or batch.",
];
for n in notes {
w.cv_ok(&["task", "note", &parent, n, "--from", "orchestrator:x"]);
}
let (out, _) = w.cv_ok(&["task", "split", &parent, "--dry-run"]);
assert!(
out.contains("# 3 DECIDE note(s)") && out.contains("(dry run: nothing written)"),
"{out}"
);
assert!(out.contains("1 other note(s) mention DECIDE mid-text"), "{out}");
let (list, _) = w.cv_ok(&["task", "list"]);
assert_eq!(
list.lines()
.filter(|l| l.starts_with(|c: char| c.is_ascii_hexdigit()))
.count(),
1,
"dry run wrote nothing:\n{list}"
);
let (out, _) = w.cv_ok(&["task", "split", &parent, "--from", "orchestrator:x"]);
assert!(out.contains("3 decision(s) created, each blocking"), "{out}");
assert!(
out.contains("move the build base from /tank to NVMe?"),
"title from the question, parens stripped:\n{out}"
);
assert!(out.contains("default: keep /tank"), "{out}");
assert!(out.contains("K-PORTAL: who births a guest cell"), "{out}");
assert!(
out.contains("option: the receiver allocates it (then F pays)"),
"{out}"
);
assert!(
out.contains("DEOS.md §5: quotes: REFERENCED by default") && out.contains("default: as proposed"),
"{out}"
);
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(out.starts_with("decisions owed (3):"), "{out}");
assert!(out.contains("assigned actions (1):"), "{out}");
assert!(
out.contains("⇒ default: the concierge births it · alt: the receiver allocates it (then F pays)"),
"{out}"
);
let (json, _) = w.cv_ok(&["task", "show", &parent, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["blocked_by"].as_array().unwrap().len(), 3, "{json}");
let (out, _) = w.cv_ok(&["task", "show", &parent, "--brief"]);
assert!(out.contains("⊘ BLOCKED"), "{out}");
assert_eq!(out.matches("→ split into ").count(), 3, "{out}");
assert!(out.contains("notes: 5 of 5 (newest last) — one line each"), "{out}");
let (list, _) = w.cv_ok(&["task", "list", "--decisions", "--json"]);
let ds: Vec<serde_json::Value> = serde_json::from_str(&list).unwrap();
assert_eq!(ds.len(), 3);
for d in &ds {
assert!(d["decision"]["source"].is_string(), "{d}");
assert_eq!(d["assignee"], "ember");
assert_eq!(d["repo"], t["repo"]);
assert!(d["tags"].as_array().unwrap().iter().any(|x| x == "split"), "{d}");
}
let (out, _) = w.cv_ok(&["task", "split", &parent]);
assert!(out.contains("0 decision(s) created"), "{out}");
assert_eq!(out.matches("(already split →").count(), 3, "{out}");
for d in &ds {
w.cv_ok(&[
"task",
"resolve",
d["task_id"].as_str().unwrap(),
"--accept-default",
"--from",
"ember",
]);
}
let (out, _) = w.cv_ok(&["task", "show", &parent]);
assert!(!out.contains("⊘ BLOCKED"), "{out}");
let (out, _) = w.cv_ok(&["task", "show", &parent, "--brief", "--notes-last", "2"]);
assert!(out.contains("notes: 2 of 5"), "{out}");
assert!(!out.contains("DECIDE: move the build base"), "{out}");
let (out, _) = w.cv_ok(&["task", "show", &parent, "--notes-grep", "k-portal"]);
assert!(out.contains("notes: 1 of 5") && out.contains("K-PORTAL"), "{out}");
}
#[test]
fn task_note_reads_stdin_and_open_absolutizes_issue() {
let w = World::new("stdin-paths");
fs::write(w.base.join("PLAN.md"), "# plan\n").unwrap();
let (ok, _, out, err) = w.cv_stdin(
&[
"task",
"open",
"with a heredoc body",
"--body",
"-",
"--issue",
"PLAN.md",
],
"a `code span` body\nwith two lines\n",
);
assert!(ok, "{err}");
let a = opened_task_id(&out);
let (json, _) = w.cv_ok(&["task", "show", &a, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["body"], "a `code span` body\nwith two lines\n");
let issue = t["issue"].as_str().unwrap();
assert!(
std::path::Path::new(issue).is_absolute() && issue.ends_with("PLAN.md"),
"{issue}"
);
assert_eq!(
std::path::Path::new(issue).canonicalize().unwrap(),
w.base.join("PLAN.md").canonicalize().unwrap()
);
let (ok, _, _, err) = w.cv_stdin(&["task", "note", &a, "-"], "note from stdin\n");
assert!(ok, "{err}");
let (out, _) = w.cv_ok(&["task", "show", &a]);
assert!(out.contains("note from stdin"), "{out}");
let (out, _) = w.cv_ok(&["task", "open", "handle", "--issue", "#42"]);
let b = opened_task_id(&out);
let (json, _) = w.cv_ok(&["task", "show", &b, "--json"]);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap()["issue"],
"#42"
);
let (out, _) = w.cv_ok(&["task", "open", "url", "--issue", "https://x.y/issues/4"]);
let c = opened_task_id(&out);
let (json, _) = w.cv_ok(&["task", "show", &c, "--json"]);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap()["issue"],
"https://x.y/issues/4"
);
let (out, _) = w.cv_ok(&["task", "open", "future doc", "--issue", "docs/later.md"]);
let d = opened_task_id(&out);
let (json, _) = w.cv_ok(&["task", "show", &d, "--json"]);
let issue = serde_json::from_str::<serde_json::Value>(&json).unwrap()["issue"]
.as_str()
.unwrap()
.to_string();
assert!(
issue.starts_with(w.base.to_str().unwrap())
|| issue.starts_with(&w.base.canonicalize().unwrap().display().to_string()),
"{issue}"
);
}
fn seeded_old_and_new(w: &World) -> (String, String) {
let old = "01900000-0000-7000-8000-000000000001".to_string(); let new = "01a00000-0000-7000-8000-000000000002".to_string();
w.seed_task_log(&[
serde_json::json!({"id": old, "task_id": old, "ts": "2026-01-01T00:00:00Z", "by": "orchestrator:old",
"event": "opened", "title": "OLD: from another project", "channel": "tasks", "assignee": "ember"}),
serde_json::json!({"id": new, "task_id": new, "ts": chrono_now(), "by": "orchestrator:new",
"event": "opened", "title": "NEW: today's task", "channel": "tasks", "assignee": "ember"}),
]);
(old, new)
}
fn chrono_now() -> String {
chrono_at(0)
}
fn chrono_at(offset: u64) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ offset;
let days = secs / 86_400;
let (h, m, s) = ((secs % 86_400) / 3600, (secs % 3600) / 60, secs % 60);
let z = days as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
#[test]
fn task_list_and_inbox_scope_to_recent_and_all_shows_old() {
let w = World::new("scope");
let (old, new) = seeded_old_and_new(&w);
let (out, _) = w.cv_ok(&["task", "list"]);
assert!(out.contains("NEW: today") && !out.contains("OLD: from"), "{out}");
assert!(
out.trim_end()
.ends_with("(1 older task(s) hidden — `--all`, or `--since 90d`)"),
"{out}"
);
let (out, _) = w.cv_ok(&["task", "list", "--all"]);
assert!(
out.contains("OLD: from") && out.contains("NEW: today") && !out.contains("hidden"),
"{out}"
);
let (out, _) = w.cv_ok(&["task", "list", "--since", "2025-12-01"]);
assert!(out.contains("OLD: from") && !out.contains("hidden"), "{out}");
let (out, _) = w.cv_ok(&["task", "list", "--since", "1d"]);
assert!(!out.contains("OLD: from") && out.contains("hidden"), "{out}");
let (ok, _, out, _) = w.cv_env(&["task", "list"], &[("CV_ENDPOINT", "orchestrator:old")]);
assert!(ok && out.contains("OLD: from") && !out.contains("hidden"), "{out}");
let (ok, _, out, _) = w.cv_env(&["task", "list"], &[("CV_ENDPOINT", "web:ember")]);
assert!(
ok && out.contains("OLD: from"),
"web:<who> ≡ <who>, the assignee:\n{out}"
);
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(out.contains("NEW: today") && !out.contains("OLD: from"), "{out}");
assert!(out.contains("(1 older item(s) hidden"), "{out}");
let (out, _) = w.cv_ok(&["task", "inbox", "ember", "--all"]);
assert!(out.contains("OLD: from") && out.contains("⏰"), "{out}");
let (md, _) = w.cv_ok(&["task", "inbox", "ember", "--md"]);
assert!(md.contains("1 older hidden (`--all`)"), "{md}");
let _ = (old, new);
}
#[test]
fn task_inbox_unread_and_events_since() {
let w = World::new("unread-events");
let (out, _) = w.cv_ok(&[
"task",
"open",
"A: touched by the orchestrator last",
"--assignee",
"ember",
"--from",
"orchestrator:x",
]);
let a = opened_task_id(&out);
let (out, _) = w.cv_ok(&[
"task",
"open",
"B: ember spoke last",
"--assignee",
"ember",
"--from",
"orchestrator:x",
]);
let b = opened_task_id(&out);
w.cv_ok(&["task", "note", &b, "on it", "--from", "ember"]);
let (out, _) = w.cv_ok(&[
"task",
"open",
"C: web ember spoke last",
"--assignee",
"ember",
"--from",
"orchestrator:x",
]);
let c = opened_task_id(&out);
w.cv_ok(&["task", "note", &c, "via the page", "--from", "web:ember"]);
let (out, _) = w.cv_ok(&["task", "inbox", "ember", "--unread"]);
assert!(
out.contains("A: touched") && !out.contains("B: ember") && !out.contains("C: web"),
"{out}"
);
let (json, _) = w.cv_ok(&["task", "inbox", "ember", "--unread", "--json"]);
assert!(json.contains(&a) && !json.contains(&b), "{json}");
let (out, err) = w.cv_ok(&["task", "events"]);
let lines: Vec<serde_json::Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 5, "{out}");
assert_eq!(lines[0]["event"], "opened");
assert_eq!(lines[0]["title"], "A: touched by the orchestrator last");
assert_eq!(lines[0]["task_state"], "open");
assert_eq!(lines[0]["assignee"], "ember");
let last_id = lines[4]["id"].as_str().unwrap().to_string();
assert!(err.contains(&format!("next: --since {last_id}")), "{err}");
let (out, _) = w.cv_ok(&["task", "events", "--since", &last_id]);
assert!(out.trim().is_empty(), "{out}");
let (out, _) = w.cv_ok(&["task", "events", "--since", lines[3]["id"].as_str().unwrap()]);
assert_eq!(out.lines().count(), 1, "{out}");
let (out, _) = w.cv_ok(&["task", "events", "--since", "1h", "--kind", "note"]);
assert_eq!(out.lines().count(), 2, "{out}");
let (out, _) = w.cv_ok(&["task", "events", "--by", "ember"]);
assert_eq!(
out.lines().count(),
2,
"web:ember is ember acting through the page:\n{out}"
);
let (out, _) = w.cv_ok(&["task", "events", "--not-by", "orchestrator:x", "--text"]);
assert_eq!(out.lines().count(), 2, "{out}");
assert!(
out.contains("noted") && out.contains("on it") && out.contains("via the page"),
"{out}"
);
let (out, _) = w.cv_ok(&["task", "events", "--task", &b]);
assert_eq!(out.lines().count(), 2, "{out}");
let (_, err) = w.cv_fails(&["task", "events", "--since", "whenever"]);
assert!(err.contains("cannot read"), "{err}");
let (ok, _, out, _) = w.cv_env(
&["task", "watch", "--assignee", "ember", "--since", "1d"],
&[("CV_ENDPOINT", "orchestrator:x")],
);
assert!(ok);
let rows: Vec<serde_json::Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(rows.len(), 2, "{out}");
assert!(rows.iter().all(|r| r["event"] == "noted"), "{out}");
}
fn http(addr: &str, method: &str, path: &str, body: Option<&str>) -> (u16, String) {
use std::io::{Read, Write};
let mut s = std::net::TcpStream::connect(addr).expect("connect to cv task serve");
s.set_read_timeout(Some(std::time::Duration::from_secs(10))).unwrap();
let body = body.unwrap_or("");
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
s.write_all(req.as_bytes()).unwrap();
let mut raw = Vec::new();
s.read_to_end(&mut raw).unwrap();
let text = String::from_utf8_lossy(&raw).into_owned();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
let status: u16 = head.split_whitespace().nth(1).unwrap_or("0").parse().unwrap_or(0);
(status, body.to_string())
}
fn serve(w: &World, who: &str) -> (std::process::Child, String) {
use std::io::{BufRead, BufReader};
let mut child = Command::new(env!("CARGO_BIN_EXE_cv"))
.args(["task", "serve", "--bind", "127.0.0.1:0", "--assignee", who])
.current_dir(&w.base)
.env("HOME", &w.home)
.env("CLUSTERVISION_HOME", &w.cv_home)
.env_remove("CV_ENDPOINT")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("cv task serve starts");
let stderr = child.stderr.take().unwrap();
let mut lines = BufReader::new(stderr).lines();
let banner = lines.next().expect("a banner line").expect("readable");
let addr = banner
.split("http://")
.nth(1)
.and_then(|rest| rest.split('/').next())
.unwrap_or_else(|| panic!("no address in banner: {banner}"))
.to_string();
std::thread::spawn(move || for _ in lines {});
(child, addr)
}
#[test]
fn task_serve_records_the_same_events_as_the_cli() {
let w = World::new("serve");
let (out, _) = w.cv_ok(&[
"task",
"decide",
"PAY §7: tariff",
"--for",
"ember",
"--default",
"1 credit/unit",
"--option",
"free tier",
"--from",
"orchestrator:x",
]);
let d = opened_task_id(&out);
let (out, _) = w.cv_ok(&[
"task",
"open",
"push dregg-infra",
"--assignee",
"ember",
"--from",
"orchestrator:x",
]);
let a = opened_task_id(&out);
let (mut child, addr) = serve(&w, "ember");
let result = std::panic::catch_unwind(|| {
let (status, html) = http(&addr, "GET", "/", None);
assert_eq!(status, 200);
assert!(
html.contains("<title>cv inbox</title>") && html.contains("/api/inbox"),
"{}",
&html[..200.min(html.len())]
);
assert!(
!html.contains("<script src=") && !html.contains("<link rel=\"stylesheet\""),
"no external resources"
);
let (status, _) = {
use std::io::{Read, Write};
let mut s = std::net::TcpStream::connect(&addr).unwrap();
s.write_all(b"GET /api/inbox HTTP/1.1\r\nHost: evil.example\r\nConnection: close\r\n\r\n")
.unwrap();
let mut raw = String::new();
s.read_to_string(&mut raw).unwrap();
(
raw.split_whitespace().nth(1).unwrap_or("0").parse::<u16>().unwrap_or(0),
raw,
)
};
assert_eq!(status, 403);
let (status, body) = http(&addr, "GET", "/api/inbox?who=ember", None);
assert_eq!(status, 200, "{body}");
let page: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(page["who"], "ember");
assert_eq!(page["counts"]["decisions"], 1);
assert_eq!(page["counts"]["assigned"], 1);
let items = page["items"].as_array().unwrap();
assert_eq!(items[0]["reason"], "decision_owed");
assert_eq!(items[0]["decision"]["default"], "1 credit/unit");
assert_eq!(items[0]["decision"]["alternatives"], serde_json::json!(["free tier"]));
assert_eq!(items[1]["reason"], "assigned_open");
let (status, body) = http(&addr, "GET", "/api/inbox", None);
assert_eq!(status, 200);
assert!(body.contains("\"who\":\"ember\""), "{body}");
let (status, body) = http(
&addr,
"POST",
&format!("/api/task/{d}/note"),
Some(r#"{"text":"reading PAY first"}"#),
);
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"by\":\"web:ember\""), "{body}");
let (status, body) = http(
&addr,
"POST",
&format!("/api/task/{d}/discuss"),
Some(r#"{"text":"what is a unit?"}"#),
);
assert_eq!(status, 200, "{body}");
let (status, body) = http(
&addr,
"POST",
&format!("/api/task/{d}/resolve"),
Some(r#"{"choice":"free tier","note":"for October"}"#),
);
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"task_state\":\"resolved\""), "{body}");
let (status, body) = http(
&addr,
"POST",
&format!("/api/task/{d}/resolve"),
Some(r#"{"accept_default":true}"#),
);
assert_eq!(status, 400, "a second resolution is refused: {body}");
let (status, body) = http(
&addr,
"POST",
&format!("/api/task/{a}/done"),
Some(r#"{"observed":"pushed"}"#),
);
assert_eq!(status, 200, "{body}");
let (status, body) = http(&addr, "POST", &format!("/api/task/{a}/done"), Some("{}"));
assert_eq!(status, 400, "{body}");
let (status, body) = http(&addr, "POST", &format!("/api/task/{a}/frobnicate"), Some("{}"));
assert_eq!(status, 400, "{body}");
let (status, body) = http(&addr, "POST", &format!("/api/task/{a}/note"), Some("not json"));
assert_eq!(status, 400, "{body}");
let (status, body) = http(&addr, "GET", "/api/inbox?who=ember", None);
assert_eq!(status, 200);
let page: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(page["items"].as_array().unwrap().len(), 0);
assert_eq!(page["closed"].as_array().unwrap().len(), 2);
let (status, body) = http(&addr, "POST", &format!("/api/task/{a}/reopen"), Some("{}"));
assert_eq!(status, 200, "{body}");
let (status, body) = http(&addr, "GET", "/api/inbox?who=ember", None);
assert_eq!(status, 200);
let page: serde_json::Value = serde_json::from_str(&body).unwrap();
let items = page["items"].as_array().unwrap();
assert_eq!(items.len(), 1, "{body}");
assert_eq!(items[0]["title"], "push dregg-infra");
assert!(
items[0]["tags"].as_array().unwrap().iter().any(|t| t == "reopened"),
"{body}"
);
let (status, body) = http(&addr, "GET", &format!("/api/task/{d}?events=1"), None);
assert_eq!(status, 200);
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["effective_state"], "resolved");
assert!(v["events"].as_array().unwrap().len() >= 6, "{body}");
let (status, body) = http(&addr, "GET", "/api/events?since=1h&kind=resolved,done", None);
assert_eq!(status, 200);
let rows: Vec<serde_json::Value> = body.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(rows.len(), 2, "{body}");
assert!(rows.iter().all(|r| r["by"] == "web:ember"), "{body}");
});
let _ = child.kill();
let _ = child.wait();
if let Err(e) = result {
std::panic::resume_unwind(e);
}
let (out, _) = w.cv_ok(&["task", "show", &d, "--brief"]);
assert!(out.contains("[resolved]"), "{out}");
assert!(
out.contains("resolved: free tier — by web:ember,") && out.contains("· for October"),
"{out}"
);
assert!(out.contains("web:ember") && out.contains("reading PAY first"), "{out}");
assert!(out.contains("NEEDS DISCUSSION: what is a unit?"), "{out}");
assert!(out.contains("tags: decision, discuss"), "{out}");
let (out, _) = w.cv_ok(&["task", "show", &a, "--brief"]);
assert!(
out.contains("[done]") && out.contains("self-reported") && out.contains("(pushed)"),
"{out}"
);
let (json, _) = w.cv_ok(&["task", "show", &a, "--events"]);
assert!(json.contains("\"by\": \"web:ember\""), "{json}");
let (out, _) = w.cv_ok(&["task", "events", "--since", "1h", "--kind", "resolved,done"]);
assert_eq!(out.lines().count(), 2, "the CLI feed and /api/events agree:\n{out}");
}
fn chain_walk(path: &std::path::Path) -> (usize, usize, serde_json::Value) {
let lines: Vec<serde_json::Value> = fs::read_to_string(path)
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let threaded: Vec<&serde_json::Value> = lines.iter().filter(|v| v.get("uuid").is_some()).collect();
let by: std::collections::HashMap<&str, &serde_json::Value> =
threaded.iter().map(|v| (v["uuid"].as_str().unwrap(), *v)).collect();
let mut cur = *threaded.last().unwrap();
let mut n = 1;
while let Some(p) = cur["parentUuid"].as_str() {
match by.get(p) {
Some(v) => {
cur = v;
n += 1;
}
None => break,
}
}
(n, threaded.len(), cur.clone())
}
#[test]
fn distill_emits_a_whole_resumable_session_and_a_sub_agent() {
let w = World::new("distill");
let big = "y".repeat(9000);
w.write_session(
"rootsess",
&[serde_json::json!({
"type": "user", "uuid": "r1", "sessionId": "rootsess", "timestamp": "2026-01-01T09:00:00Z",
"cwd": "/work/proj", "message": {"role": "user", "content": "run the lanes"}
})],
);
let sub = w.home.join(".claude/projects/-work-proj/rootsess/subagents");
fs::create_dir_all(&sub).unwrap();
let agent = sub.join("agent-a0123456789abcdef.jsonl");
let line = |v: serde_json::Value| {
let mut v = v;
v["isSidechain"] = true.into();
v["agentId"] = "a0123456789abcdef".into();
v["sessionId"] = "rootsess".into();
v["cwd"] = "/work/proj".into();
format!("{v}\n")
};
let body = [
line(serde_json::json!({"type": "user", "uuid": "u0", "parentUuid": null, "timestamp": "2026-01-01T10:00:00Z",
"message": {"role": "user", "content": "You are lane TEST. Build it on box1 and commit on lane/test."}})),
line(serde_json::json!({"type": "assistant", "uuid": "a1", "parentUuid": "u0", "timestamp": "2026-01-01T10:01:00Z",
"message": {"role": "assistant", "model": "claude-test-1", "content": [
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ssh ember@box1 'cat big.log'", "description": "Read the big log"}}]}})),
line(serde_json::json!({"type": "user", "uuid": "u1", "parentUuid": "a1", "timestamp": "2026-01-01T10:02:00Z",
"message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": big, "is_error": false}]}})),
line(serde_json::json!({"type": "attachment", "uuid": "h1", "parentUuid": "u1", "timestamp": "2026-01-01T10:02:30Z",
"attachment": {"type": "hook_success", "hookName": "PostToolUse", "content": ""}})),
line(serde_json::json!({"type": "assistant", "uuid": "a2", "parentUuid": "h1", "timestamp": "2026-01-01T10:03:00Z",
"message": {"role": "assistant", "model": "claude-test-1", "content": [
{"type": "text", "text": "Chose a rebase: the queue wants linear ranges."},
{"type": "tool_use", "id": "t2", "name": "Bash", "input": {"command": "cd /srv/x && git commit -F msg", "description": "Commit"}}]}})),
line(serde_json::json!({"type": "user", "uuid": "u2", "parentUuid": "a2", "timestamp": "2026-01-01T10:04:00Z",
"message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "[lane/test 1234abcd] kernel: one", "is_error": false}]}})),
line(serde_json::json!({"type": "assistant", "uuid": "a3", "parentUuid": "u2", "timestamp": "2026-01-01T10:05:00Z",
"message": {"role": "assistant", "model": "claude-test-1", "content": [{"type": "text", "text": "Committed."}],
"usage": {"input_tokens": 5, "cache_read_input_tokens": 900000, "output_tokens": 10}}})),
]
.concat();
fs::write(&agent, &body).unwrap();
let agent_s = agent.to_str().unwrap();
let (out, err) = w.cv_ok(&["distill", agent_s, "--keep-last", "1"]);
assert!(out.contains("You are lane TEST."), "{out}");
assert!(out.contains("Chose a rebase"), "{out}");
assert!(out.contains("`lane/test` `1234abcd`"), "{out}");
assert!(out.contains("`ember@box1`"), "{out}");
assert!(!out.contains(&"y".repeat(200)), "the big output must be elided");
assert!(err.contains("distilled"), "{err}");
let (out, _) = w.cv_ok(&["distill", agent_s, "--keep-last", "1", "--session", "--json"]);
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("stdout must be pure JSON");
let path = PathBuf::from(v["emitted"]["path"].as_str().unwrap());
let new_id = v["emitted"]["session_id"].as_str().unwrap().to_string();
let (reached, threaded, root) = chain_walk(&path);
assert_eq!(reached, threaded, "a dangling parent truncates the resumed context");
assert!(
root["message"]["content"].as_str().unwrap().contains("[cv distill]"),
"{root}"
);
let text = fs::read_to_string(&path).unwrap();
assert!(!text.contains("\"isSidechain\":true"), "a main-thread session");
assert!(
!text.contains("900000"),
"the source's near-limit usage must not reach the resume gate"
);
let (cat, _) = w.cv_ok(&["cat", &new_id, "t1"]);
assert!(cat.contains(&"y".repeat(9000)), "cv cat must return the elided payload");
let (out, _) = w.cv_ok(&[
"distill",
agent_s,
"--keep-last",
"1",
"--agent-of",
"rootsess",
"--json",
]);
let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
let aid = v["emitted"]["agent_id"].as_str().unwrap();
let apath = sub.join(format!("agent-{aid}.jsonl"));
assert!(apath.exists() && sub.join(format!("agent-{aid}.meta.json")).exists());
let (reached, threaded, _) = chain_walk(&apath);
assert_eq!(reached, threaded);
for l in fs::read_to_string(&apath).unwrap().lines() {
let r: serde_json::Value = serde_json::from_str(l).unwrap();
assert_eq!(r["isSidechain"], true, "{l}");
assert_eq!(r["agentId"], aid, "{l}");
assert_eq!(r["sessionId"], "rootsess", "{l}");
}
assert_eq!(
fs::read_to_string(&agent).unwrap(),
body,
"distill must never modify the source"
);
}
#[test]
fn adopt_moves_a_stranded_lane_into_the_live_session() {
let w = World::new("adopt");
let (dead, live, aid) = ("deadsess", "livesess", "a0123456789abcdef");
let root_line = |sid: &str| {
serde_json::json!({"type": "user", "uuid": format!("{sid}-1"), "sessionId": sid,
"timestamp": "2026-01-01T09:00:00Z", "cwd": "/work/proj",
"message": {"role": "user", "content": "run the lanes"}})
};
w.write_session(dead, &[root_line(dead)]);
let sub = w.home.join(".claude/projects/-work-proj/deadsess/subagents");
fs::create_dir_all(&sub).unwrap();
let line = |v: serde_json::Value| {
let mut v = v;
v["isSidechain"] = true.into();
v["agentId"] = aid.into();
v["sessionId"] = dead.into();
v["cwd"] = "/work/proj".into();
format!("{v}\n")
};
let body = [
line(serde_json::json!({"type": "user", "uuid": "u0", "parentUuid": null, "timestamp": "2026-01-01T10:00:00Z",
"message": {"role": "user", "content": "You are lane TEST."}})),
line(serde_json::json!({"type": "assistant", "uuid": "a1", "parentUuid": "u0", "timestamp": "2026-01-01T10:01:00Z",
"message": {"role": "assistant", "model": "claude-test-1", "content": [
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "grep sessionId x.jsonl"}}]}})),
line(serde_json::json!({"type": "user", "uuid": "u1", "parentUuid": "a1", "timestamp": "2026-01-01T10:02:00Z",
"message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1",
"content": "{\"sessionId\":\"deadsess\"}", "is_error": false}]}})),
]
.concat();
let agent = sub.join(format!("agent-{aid}.jsonl"));
fs::write(&agent, &body).unwrap();
let meta = r#"{"agentType":"general-purpose","description":"Lane TEST","model":"opus"}"#;
fs::write(sub.join(format!("agent-{aid}.meta.json")), meta).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
w.write_session(live, &[root_line(live)]);
let (out, _) = w.cv_ok(&["adopt", "--list", dead]);
assert!(
out.contains(aid) && out.contains("Lane TEST") && out.contains("yes"),
"{out}"
);
let live_sub = w.home.join(".claude/projects/-work-proj/livesess/subagents");
let (_, err) = w.cv_ok(&["adopt", &aid[..8], "--dry-run"]);
assert!(err.contains("into livesess"), "the default target is announced: {err}");
assert!(err.contains("restamped on 3 of 3 lines"), "{err}");
assert!(
err.contains(&format!("\"to\": \"{aid}\"")),
"the SendMessage incantation: {err}"
);
assert!(err.contains("cv cat deadsess"), "the sidecar reminder: {err}");
assert!(!live_sub.exists(), "a dry run writes nothing");
w.cv_ok(&["adopt", aid]);
let copied = fs::read_to_string(live_sub.join(format!("agent-{aid}.jsonl"))).unwrap();
assert_eq!(
copied,
body.replace("\"sessionId\":\"deadsess\"", "\"sessionId\":\"livesess\"")
.replace(
"{\\\"sessionId\\\":\\\"livesess\\\"}",
"{\\\"sessionId\\\":\\\"deadsess\\\"}"
)
);
assert!(
copied.contains("{\\\"sessionId\\\":\\\"deadsess\\\"}"),
"content untouched"
);
assert_eq!(
fs::read_to_string(live_sub.join(format!("agent-{aid}.meta.json"))).unwrap(),
meta
);
assert_eq!(
fs::read_to_string(&agent).unwrap(),
body,
"the source is never modified"
);
fs::write(live_sub.join(format!("agent-{aid}.jsonl")), "sentinel\n").unwrap();
let (ok, _, _, err) = w.cv(&["adopt", aid, "--from", dead, "--into", live]);
assert!(!ok && err.contains("refusing to overwrite"), "{err}");
assert_eq!(
fs::read_to_string(live_sub.join(format!("agent-{aid}.jsonl"))).unwrap(),
"sentinel\n"
);
w.cv_ok(&["adopt", aid, "--from", dead, "--into", live, "--force"]);
assert!(fs::read_to_string(live_sub.join(format!("agent-{aid}.jsonl")))
.unwrap()
.contains("livesess"));
let (out, _) = w.cv_ok(&["cat", &format!("agent-{aid}"), "t1"]);
assert!(out.contains("deadsess"), "{out}");
}
#[test]
fn adopt_reaches_extra_claude_roots_and_never_targets_the_dead_session() {
let w = World::new("adopt-roots");
let (dead, live, aid) = ("deadsess", "livesess", "afedcba9876543210");
let root_line = |sid: &str| {
serde_json::json!({"type": "user", "uuid": format!("{sid}-1"), "sessionId": sid,
"timestamp": "2026-01-01T09:00:00Z", "cwd": "/work/proj",
"message": {"role": "user", "content": "run the lanes"}})
};
w.write_session(live, &[root_line(live)]);
std::thread::sleep(std::time::Duration::from_millis(20));
let seat_proj = w.home.join("seats/bonsai/claude/projects/-work-proj");
let sub = seat_proj.join(dead).join("subagents");
fs::create_dir_all(&sub).unwrap();
fs::write(
seat_proj.join(format!("{dead}.jsonl")),
format!("{}\n", root_line(dead)),
)
.unwrap();
let mut l = serde_json::json!({"type": "user", "uuid": "u0", "parentUuid": null, "timestamp": "2026-01-01T10:00:00Z",
"cwd": "/work/proj", "message": {"role": "user", "content": "You are lane SEAT."}});
l["isSidechain"] = true.into();
l["agentId"] = aid.into();
l["sessionId"] = dead.into();
fs::write(sub.join(format!("agent-{aid}.jsonl")), format!("{l}\n")).unwrap();
let (ok, _, _, err) = w.cv(&["adopt", aid, "--dry-run"]);
assert!(!ok && err.contains("no sub-agent"), "{err}");
fs::write(w.cv_home.join("claude-roots"), "~/seats/*/claude\n").unwrap();
let (_, err) = w.cv_ok(&["adopt", aid, "--dry-run"]);
assert!(
err.contains("into livesess"),
"the dead session is newer but is never the target: {err}"
);
w.cv_ok(&["adopt", aid]);
let copied = w
.home
.join(".claude/projects/-work-proj/livesess/subagents")
.join(format!("agent-{aid}.jsonl"));
assert!(fs::read_to_string(copied)
.unwrap()
.contains("\"sessionId\":\"livesess\""));
let (out, _) = w.cv_ok(&["adopt", "--list", dead]);
assert!(out.contains(aid), "a seat session lists by id too: {out}");
}
#[test]
fn task_notes_after_close_and_close_with_a_note() {
let w = World::new("post-close");
let (out, _) = w.cv_ok(&["task", "open", "ship the thing"]);
let a = opened_task_id(&out);
let evidence = w.base.join("evidence.md");
fs::write(&evidence, "journey J4: expected 7 / got 7\n").unwrap();
let ev = evidence.to_str().unwrap();
let (out, _) = w.cv_ok(&["task", "done", &a, "--note-file", ev, "--from", "lane:x"]);
assert!(
out.contains("✦ noted") && out.contains("✦ done"),
"both events reported:\n{out}"
);
let (json, _) = w.cv_ok(&["task", "show", &a, "--events"]);
let kinds: Vec<&str> = json
.lines()
.filter_map(|l| l.trim().strip_prefix("\"event\": \""))
.map(|k| k.trim_end_matches(['"', ',']))
.collect();
assert_eq!(
kinds,
["opened", "noted", "done"],
"the note precedes the close:\n{json}"
);
let (out, _) = w.cv_ok(&["task", "note", &a, "the report is at /tmp/r.md", "--from", "lane:x"]);
assert!(out.contains("✦ noted") && out.contains("→ done"), "{out}");
let (out, _) = w.cv_ok(&["task", "show", &a]);
assert!(out.contains("[done]"), "{out}");
assert!(out.contains("journey J4: expected 7 / got 7"), "{out}");
assert!(
!out.contains("journey J4: expected 7 / got 7\n (after close)"),
"{out}"
);
assert!(out.contains("(after close): the report is at /tmp/r.md"), "{out}");
let (json, _) = w.cv_ok(&["task", "show", &a, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["state"], "done");
assert!(t["notes"][0].get("post_close").is_none(), "{json}");
assert_eq!(t["notes"][1]["post_close"], true, "{json}");
let (_, err) = w.cv_fails(&["task", "tag", &a, "late"]);
assert!(err.contains("cannot apply tagged"), "{err}");
let (out, _) = w.cv_ok(&["task", "open", "the wrong approach"]);
let b = opened_task_id(&out);
w.cv_ok(&[
"task",
"abandon",
&b,
"--reason",
"superseded by design",
"--note",
"why: see the audit",
]);
let (out, _) = w.cv_ok(&["task", "show", &b]);
assert!(
out.contains("[abandoned]") && out.contains("why: see the audit"),
"{out}"
);
assert!(
!out.contains("(after close)"),
"the abandon note preceded the close:\n{out}"
);
let (_, err) = w.cv_fails(&["task", "done", &b, "--note", "too late to finish"]);
assert!(err.contains("event rejected"), "{err}");
let (json, _) = w.cv_ok(&["task", "show", &b, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
t["notes"].as_array().unwrap().len(),
1,
"the refused batch left nothing:\n{json}"
);
}
#[test]
fn task_provisional_resolution_veto_and_confirm() {
let w = World::new("provisional");
let (out, _) = w.cv_ok(&[
"task",
"decide",
"build base: /tank or NVMe",
"--for",
"ember",
"--default",
"keep /tank",
"--option",
"move to NVMe",
"--provisional",
"--from",
"orchestrator:o",
]);
let d = opened_task_id(&out);
assert!(
out.contains("resolved") && out.contains("provisionally on the default"),
"{out}"
);
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(
out.starts_with("made for you (veto?) (1):"),
"not owed, its own group:\n{out}"
);
assert!(
out.contains("⇒ made: keep /tank — by orchestrator:o · veto to: move to NVMe"),
"{out}"
);
assert!(out.contains("cv task resolve <id> --confirm --from ember"), "{out}");
let (json, _) = w.cv_ok(&["task", "inbox", "ember", "--json"]);
assert!(json.contains("\"provisional\""), "{json}");
let (out, _) = w.cv_ok(&["task", "show", &d]);
assert!(out.contains("[resolved]") && out.contains("PROVISIONAL"), "{out}");
assert!(out.contains("veto?: cv task resolve"), "{out}");
let (out, _) = w.cv_ok(&[
"task",
"decide",
"Q2",
"--for",
"ember",
"--default",
"a",
"--option",
"b",
"--from",
"orchestrator:o",
]);
let d2 = opened_task_id(&out);
let (_, err) = w.cv_fails(&[
"task",
"resolve",
&d2,
"--choice",
"b",
"--provisional",
"--from",
"orchestrator:o",
]);
assert!(err.contains("stands on the default"), "{err}");
let (_, err) = w.cv_fails(&[
"task",
"resolve",
&d,
"--choice",
"move to NVMe",
"--from",
"lane:someone",
]);
assert!(err.contains("only the decision's assignee (ember)"), "{err}");
let (_, err) = w.cv_fails(&["task", "resolve", &d2, "--confirm", "--from", "ember"]);
assert!(err.contains("no provisional resolution to confirm"), "{err}");
let (out, _) = w.cv_ok(&["task", "resolve", &d, "--choice", "move to NVMe", "--from", "ember"]);
assert!(out.contains("→ move to NVMe (by ember)"), "{out}");
let (out, _) = w.cv_ok(&["task", "show", &d]);
assert!(out.contains("provisionally: keep /tank — by orchestrator:o"), "{out}");
assert!(
out.contains("resolved: move to NVMe — by ember") && !out.contains("PROVISIONAL"),
"{out}"
);
let (json, _) = w.cv_ok(&["task", "show", &d, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["decision"]["resolution"]["choice"], "move to NVMe");
assert!(t["decision"]["resolution"].get("provisional").is_none(), "{json}");
assert_eq!(t["decision"]["superseded_provisional"]["provisional"], true, "{json}");
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(
out.contains("(inbox empty for ember)") || !out.contains("made for you"),
"{out}"
);
let (_, err) = w.cv_fails(&["task", "resolve", &d, "--accept-default", "--from", "ember"]);
assert!(err.contains("cannot apply resolved"), "{err}");
let (out, _) = w.cv_ok(&[
"task",
"decide",
"Q3",
"--for",
"ember",
"--default",
"x",
"--option",
"y",
"--provisional",
"--from",
"orchestrator:o",
]);
let d3 = opened_task_id(&out);
let (mut child, addr) = serve(&w, "ember");
let result = std::panic::catch_unwind(|| {
let (status, body) = http(&addr, "GET", "/api/inbox?who=ember", None);
assert_eq!(status, 200, "{body}");
let page: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(page["counts"]["provisional"], 1, "{body}");
assert_eq!(page["counts"]["decisions"], 1, "Q2 is still owed; Q3 is not: {body}");
let made: Vec<&serde_json::Value> = page["items"]
.as_array()
.unwrap()
.iter()
.filter(|i| i["reason"] == "provisional")
.collect();
assert_eq!(made.len(), 1, "{body}");
assert_eq!(made[0]["title"], "Q3");
let (status, body) = http(
&addr,
"POST",
&format!("/api/task/{d3}/resolve"),
Some(r#"{"confirm":true}"#),
);
assert_eq!(status, 200, "{body}");
});
let _ = child.kill();
let _ = child.wait();
if let Err(e) = result {
std::panic::resume_unwind(e);
}
let (json, _) = w.cv_ok(&["task", "show", &d3, "--json"]);
let t: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(t["decision"]["resolution"]["by"], "web:ember");
assert_eq!(t["decision"]["resolution"]["choice"], "x");
assert_eq!(t["decision"]["superseded_provisional"]["by"], "orchestrator:o");
}
#[test]
fn task_decision_tag_without_options_is_refused() {
let w = World::new("decision-tag");
let (_, err) = w.cv_fails(&[
"task",
"open",
"pick a rate",
"--assignee",
"ember",
"--tags",
"deploy,decision",
]);
assert!(
err.contains("a decision is posed, not tagged") && err.contains("cv task decide \"pick a rate\""),
"{err}"
);
assert!(err.contains("--default") && err.contains("is required"), "{err}");
let (out, _) = w.cv_ok(&["task", "list", "--all"]);
assert!(
out.contains("(no matching tasks)"),
"a refused open leaves nothing behind:\n{out}"
);
let (out, _) = w.cv_ok(&["task", "open", "plain work", "--assignee", "ember"]);
let a = opened_task_id(&out);
let (_, err) = w.cv_fails(&["task", "tag", &a, "decision"]);
assert!(
err.contains("a decision is posed, not tagged") && err.contains("--blocks"),
"{err}"
);
let (out, _) = w.cv_ok(&["task", "show", &a, "--json"]);
assert!(!out.contains("\"decision\""), "{out}");
let (out, _) = w.cv_ok(&["task", "decide", "a real one", "--for", "ember", "--default", "x"]);
let d = opened_task_id(&out);
w.cv_ok(&["task", "tag", &d, "decision,urgent"]);
w.plant_legacy_tag_decision("01a0f000-0000-7000-8000-00000000000c", "legacy tag-only", "ember");
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(
out.starts_with("decisions owed (2):") && out.contains("legacy tag-only"),
"{out}"
);
}
#[test]
fn task_status_is_pinned_replaced_and_shown_first() {
let w = World::new("status");
let (out, _) = w.cv_ok(&[
"task",
"open",
"INTEGRATOR: own final",
"--assignee",
"lane:integrator-1",
"--from",
"orchestrator:o",
]);
let t = opened_task_id(&out);
w.cv_ok(&["task", "note", &t, "merged braid-host", "--from", "lane:integrator-1"]);
let f = w.base.join("status.md");
fs::write(&f, "tip 4ad19d9a\nmerged: braid-host\nnext: braid-proof\n").unwrap();
let (_, err) = w.cv_fails(&["task", "status", &t, "--file", f.to_str().unwrap()]);
assert!(
err.contains("set CV_ENDPOINT or pass --from"),
"identity-bearing:\n{err}"
);
w.cv_ok(&[
"task",
"status",
&t,
"--file",
f.to_str().unwrap(),
"--from",
"lane:integrator-1",
]);
let (_, err) = w.cv_fails(&["task", "status", &t, "hijack", "--from", "lane:stranger"]);
assert!(
err.contains("only its assignee (lane:integrator-1) or its opener (orchestrator:o)"),
"{err}"
);
let (ok, _, _, err) = w.cv_full(
&["task", "status", &t, "-", "--from", "orchestrator:o"],
&[],
Some("tip 8411b1bf\nmerged: braid-host, braid-proof\nnext: braid-rooms\n"),
);
assert!(ok, "{err}");
let (out, _) = w.cv_ok(&["task", "show", &t]);
let status_at = out.find(" STATUS (orchestrator:o,").expect(&out);
assert!(status_at < out.find(" title:").unwrap(), "STATUS leads:\n{out}");
assert!(
out.contains("revision 2") && out.contains(" next: braid-rooms"),
"{out}"
);
assert!(!out.contains("next: braid-proof"), "replaced, not appended:\n{out}");
let (out, _) = w.cv_ok(&["task", "show", &t, "--brief"]);
assert!(
out.contains("STATUS") && out.contains("title: INTEGRATOR: own final"),
"{out}"
);
assert!(
!out.contains("merged braid-host\n") && !out.contains("note"),
"only title + STATUS:\n{out}"
);
let (out, _) = w.cv_ok(&["task", "show", &t, "--status"]);
assert_eq!(
out,
"tip 8411b1bf\nmerged: braid-host, braid-proof\nnext: braid-rooms\n"
);
let (json, _) = w.cv_ok(&["task", "show", &t, "--json"]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["status"]["by"], "orchestrator:o");
assert_eq!(v["status"]["revisions"], 2);
let (out, _) = w.cv_ok(&["task", "events", "--task", &t, "--kind", "status"]);
assert_eq!(out.lines().count(), 2, "history kept in the events:\n{out}");
let (out, _) = w.cv_ok(&["task", "open", "plain"]);
let p = opened_task_id(&out);
let (_, err) = w.cv_fails(&["task", "show", &p, "--status"]);
assert!(
err.contains("no STATUS pinned") && err.contains("cv task status"),
"{err}"
);
w.cv_ok(&["task", "done", &t, "--from", "lane:integrator-1"]);
let (_, err) = w.cv_fails(&["task", "status", &t, "late", "--from", "lane:integrator-1"]);
assert!(err.contains("cannot apply status_set"), "{err}");
}
#[test]
fn task_decide_provisional_is_one_unit_for_a_token_bound_poser() {
let w = World::new("decide-bound");
let (out, _) = w.cv_ok(&["task", "open", "warm-up"]);
let a = opened_task_id(&out);
w.cv_ok(&["task", "claim", &a, "--from", "orchestrator:o", "--token", "s3cret"]);
let decide = [
"task",
"decide",
"build base: /tank or NVMe",
"--for",
"ember",
"--default",
"keep /tank",
"--option",
"move to NVMe",
"--provisional",
"--from",
"orchestrator:o",
];
let (out, err) = w.cv_fails(&decide);
assert!(err.contains("token-bound"), "{err}");
assert!(!out.contains("posed for"), "nothing is announced as posed:\n{out}");
let (out, _) = w.cv_ok(&["task", "list", "--all"]);
assert!(
!out.contains("build base"),
"the refused unit left no task behind:\n{out}"
);
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(!out.contains("build base"), "nothing is owed:\n{out}");
let mut with_token = decide.to_vec();
with_token.extend(["--token", "s3cret"]);
let (out, _) = w.cv_ok(&with_token);
let d = opened_task_id(&out);
assert!(
out.contains("posed for ember") && out.contains("provisionally on the default"),
"{out}"
);
let (json, _) = w.cv_ok(&["task", "show", &d, "--events"]);
let kinds: Vec<&str> = json
.lines()
.filter_map(|l| l.trim().strip_prefix("\"event\": \""))
.map(|k| k.trim_end_matches(['"', ',']))
.collect();
assert_eq!(kinds, ["opened", "tagged", "posed", "resolved"], "{json}");
let (out, _) = w.cv_ok(&["task", "inbox", "ember"]);
assert!(out.starts_with("made for you (veto?) (1):"), "{out}");
}