use std::fs;
use std::path::{Path, PathBuf};
use atheneum::graph::{watch_decisions, AtheneumGraph, WatchConfig};
use serde_json::{json, Value};
fn record(rec_type: &str, content: Vec<Value>) -> String {
json!({
"type": rec_type,
"message": { "role": rec_type, "content": content },
"sessionId": "sess_watch",
})
.to_string()
}
fn text_block(text: &str) -> Value {
json!({ "type": "text", "text": text })
}
fn tool_use(id: &str, name: &str, input: Value) -> Value {
json!({ "type": "tool_use", "id": id, "name": name, "input": input })
}
fn tool_result(id: &str, content: &str) -> Value {
json!({ "type": "tool_result", "tool_use_id": id, "content": content })
}
fn write_transcript(tmp: &Path, sid: &str, lines: &[String]) -> PathBuf {
let proj_dir = tmp.join(".claude").join("projects").join("proj_watch");
fs::create_dir_all(&proj_dir).unwrap();
let path = proj_dir.join(format!("{sid}.jsonl"));
let mut body = String::new();
for line in lines {
body.push_str(line);
body.push('\n');
}
fs::write(&path, body).unwrap();
path
}
fn run_once(db: &Path, config_dir: &Path) -> atheneum::graph::WatchStats {
let graph = AtheneumGraph::open(db).unwrap();
let config = WatchConfig {
config_dirs: vec![config_dir.to_path_buf()],
interval: std::time::Duration::from_secs(1),
project: Some("watchtest".to_string()),
agent: "claude".to_string(),
dry_run: false,
once: true,
};
watch_decisions(&graph, &config).unwrap()
}
fn decision_rows(db: &Path, session_id: &str) -> Vec<Value> {
let graph = AtheneumGraph::open(db).unwrap();
let rows = graph
.recent_discoveries(None, None, Some(session_id), None, 1000)
.unwrap();
rows.into_iter().map(|e| e.data).collect::<Vec<_>>()
}
#[test]
fn askuser_question_emits_one_decision_with_chosen_label() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let tool_use = tool_use(
"call_auq1",
"AskUserQuestion",
json!({
"questions": [{
"question": "Which index?",
"header": "Index",
"multiSelect": false,
"options": [
{ "label": "HNSW", "description": "fast ANN" },
{ "label": "brute", "description": "exact but slow" }
]
}]
}),
);
let result = tool_result(
"call_auq1",
r#"Your questions have been answered: "Which index?"="HNSW". You can now continue."#,
);
let lines = vec![
record("assistant", vec![text_block("asking"), tool_use]),
record("user", vec![result]),
];
write_transcript(&tmp, "sess_watch", &lines);
let stats = run_once(&db, &config_dir);
assert_eq!(
stats.decisions_emitted, 1,
"exactly one decision: {:?}",
stats
);
assert_eq!(stats.decisions_skipped, 0);
let rows = decision_rows(&db, "sess_watch");
assert_eq!(rows.len(), 1);
let d = &rows[0];
assert_eq!(d["source"], json!("askuser"));
assert_eq!(d["chosen"], json!("HNSW"));
assert_eq!(d["target"], json!("Index"), "target = header");
assert_eq!(d["alternatives"], json!(["HNSW", "brute"]));
assert_eq!(
d["rationale"],
json!("fast ANN"),
"rationale = chosen option's description"
);
}
#[test]
fn exitplan_mode_emits_decision_only_when_allowed_prompts_present() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let with_prompts = tool_use(
"call_ep1",
"ExitPlanMode",
json!({ "allowedPrompts": ["cargo build", "cargo test"] }),
);
let empty = tool_use("call_ep2", "ExitPlanMode", json!({}));
let lines = vec![
record("assistant", vec![text_block("plan a"), with_prompts]),
record("assistant", vec![text_block("plan b"), empty]),
];
write_transcript(&tmp, "sess_watch", &lines);
let stats = run_once(&db, &config_dir);
assert_eq!(
stats.decisions_emitted, 1,
"only the allowedPrompts one: {:?}",
stats
);
let rows = decision_rows(&db, "sess_watch");
assert_eq!(rows.len(), 1);
let d = &rows[0];
assert_eq!(d["source"], json!("exitplan"));
assert_eq!(d["chosen"], json!("proceed"));
assert_eq!(d["target"], json!("plan-approval"));
assert_eq!(d["alternatives"], json!(["cargo build", "cargo test"]));
}
#[test]
fn taskcreate_emits_decision_with_subject_as_target() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let tu = tool_use(
"call_tc1",
"TaskCreate",
json!({
"subject": "Create scorer schema",
"description": "tables in src/graph/scorer/schema.rs",
"activeForm": "Creating scorer schema"
}),
);
let lines = vec![record("assistant", vec![text_block("planning"), tu])];
write_transcript(&tmp, "sess_watch", &lines);
let stats = run_once(&db, &config_dir);
assert_eq!(stats.decisions_emitted, 1, "{:?}", stats);
let rows = decision_rows(&db, "sess_watch");
assert_eq!(rows.len(), 1);
let d = &rows[0];
assert_eq!(d["source"], json!("taskcreate"));
assert_eq!(d["target"], json!("Create scorer schema"));
assert_eq!(d["chosen"], json!("Create scorer schema"));
assert_eq!(
d["rationale"],
json!("tables in src/graph/scorer/schema.rs")
);
}
#[test]
fn todowrite_emits_only_for_new_tasks() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let tw1 = tool_use(
"call_tw1",
"TodoWrite",
json!({
"todos": [
{ "content": "task A", "status": "pending" },
{ "content": "task B", "status": "pending" }
]
}),
);
let tw2 = tool_use(
"call_tw2",
"TodoWrite",
json!({
"todos": [
{ "content": "task A", "status": "completed" },
{ "content": "task C", "status": "pending" }
]
}),
);
let lines = vec![
record("assistant", vec![text_block("first"), tw1]),
record("assistant", vec![text_block("second"), tw2]),
];
write_transcript(&tmp, "sess_watch", &lines);
let stats = run_once(&db, &config_dir);
assert_eq!(stats.decisions_emitted, 3, "{:?}", stats);
let rows = decision_rows(&db, "sess_watch");
let sources: Vec<&str> = rows.iter().map(|d| d["source"].as_str().unwrap()).collect();
assert!(sources.iter().all(|s| *s == "todowrite"));
assert_eq!(rows.len(), 3);
}
#[test]
fn rescan_same_bytes_is_idempotent() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let tu = tool_use(
"call_tc_idem",
"TaskCreate",
json!({ "subject": "do the thing", "description": "desc" }),
);
let lines = vec![record("assistant", vec![text_block("go"), tu])];
write_transcript(&tmp, "sess_watch", &lines);
let s1 = run_once(&db, &config_dir);
assert_eq!(s1.decisions_emitted, 1);
let s2 = run_once(&db, &config_dir);
assert_eq!(
s2.decisions_emitted, 0,
"second scan must emit nothing: {:?}",
s2
);
assert_eq!(
decision_rows(&db, "sess_watch").len(),
1,
"row count stable at 1"
);
}
#[test]
fn decisions_are_session_scoped() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let tu = tool_use(
"call_tc_scope",
"TaskCreate",
json!({ "subject": "scoped task", "description": "" }),
);
let lines = vec![record("assistant", vec![text_block("x"), tu])];
write_transcript(&tmp, "sess_watch", &lines);
let _ = run_once(&db, &config_dir);
let rows = decision_rows(&db, "sess_watch");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["session_id"], json!("sess_watch"));
let graph = AtheneumGraph::open(&db).unwrap();
let other = graph
.recent_discoveries(None, None, Some("sess_other"), None, 1000)
.unwrap();
assert!(other.is_empty());
}
#[test]
fn partial_final_line_is_tolerated_not_fabricated() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let tu = tool_use(
"call_tc_partial",
"TaskCreate",
json!({ "subject": "finished task", "description": "" }),
);
let complete_line = record("assistant", vec![text_block("x"), tu]);
let proj_dir = tmp.join(".claude").join("projects").join("proj_watch");
fs::create_dir_all(&proj_dir).unwrap();
let path = proj_dir.join("sess_watch.jsonl");
fs::write(
&path,
format!("{complete_line}\n{{\"type\":\"assistant\",\"message\":"),
)
.unwrap();
let stats = run_once(&db, &config_dir);
assert_eq!(
stats.decisions_emitted, 1,
"only the complete line: {:?}",
stats
);
assert_eq!(decision_rows(&db, "sess_watch").len(), 1);
}
#[test]
fn fresh_once_rerun_uses_dedup_safety_net() {
let tmp = tempfile_dir();
let db = tmp.join("watch.db");
let config_dir = tmp.join(".claude");
let tu = tool_use(
"call_tc_rerun",
"TaskCreate",
json!({ "subject": "stable task", "description": "" }),
);
let lines = vec![record("assistant", vec![text_block("x"), tu])];
write_transcript(&tmp, "sess_watch", &lines);
let s1 = run_once(&db, &config_dir);
assert_eq!(s1.decisions_emitted, 1);
let s2 = run_once(&db, &config_dir);
assert_eq!(s2.decisions_emitted, 0, "rerun emits nothing: {:?}", s2);
assert_eq!(
s2.decisions_skipped, 1,
"the re-read line is deduped: {:?}",
s2
);
assert_eq!(
decision_rows(&db, "sess_watch").len(),
1,
"row count stable"
);
}
fn tempfile_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"atheneum-watch-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}