use std::path::{Path, PathBuf};
use std::process::Command;
use agentstatedeveloper_core::{
AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, IndexStore, LedgerEntry, LedgerKind,
LedgerStore, Position, SearchFtsDb, Symbol, SymbolKind,
};
fn asd_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_asd"))
}
fn mk_sym(sym_id: &str, qname: &str, file: &str) -> Symbol {
Symbol {
symbol_id: sym_id.into(),
symbol_fp: format!("fp-{sym_id}"),
qname: qname.into(),
language: "python".into(),
kind: SymbolKind::Function,
file: file.into(),
start: Position { line: 1, col: 0 },
end: Position { line: 5, col: 0 },
signature: Some(format!(
"def {}()",
qname.rsplit('.').next().unwrap_or(qname)
)),
doc: Some(format!("Function {qname}")),
}
}
fn append_thinking(
engine: &Engine,
sym_id: &str,
kind: LedgerKind,
summary: &str,
confidence: Option<f64>,
body: Option<&str>,
) {
let ledger = AsgLedgerStore::from_engine(engine);
let mut e = LedgerEntry::new(
sym_id,
kind,
summary,
Author {
kind: AuthorKind::Agent,
id: "alice".into(),
},
);
e.confidence = confidence;
e.body = body.map(str::to_string);
ledger.append_entry(&engine.ref_name, &e, "alice").unwrap();
}
fn seed_drift_pad_world(db: &Path) {
let engine = Engine::open_sqlite(db).expect("open");
let idx = AsgIndexStore::from_engine(&engine);
let sym = mk_sym(
"sym_sync",
"Sources.Sequencer.syncDriftScheduler",
"Sources/Sequencer/SyncDriftScheduler.swift",
);
idx.put_symbol(&engine.ref_name, &sym, "t").unwrap();
let fts = SearchFtsDb::open(db).unwrap();
fts.rebuild(&[sym.clone()]).expect("fts rebuild");
append_thinking(
&engine,
"sym_sync",
LedgerKind::Hypothesis,
"scheduler routing must preserve DriftSynthPool sink overrides",
Some(0.8),
None,
);
append_thinking(
&engine,
"sym_sync",
LedgerKind::MentalModel,
"drift-pad-pipeline: Drift Pad → compile → LaneWorkspace stubs → Scheduler",
None,
Some(r#"{"name":"drift-pad-pipeline","symbols":["Sources.Sequencer.syncDriftScheduler"]}"#),
);
append_thinking(
&engine,
"sym_sync",
LedgerKind::OpenQuestion,
"should Drift Pad fully replace legacy lane playback?",
None,
None,
);
append_thinking(
&engine,
"sym_sync",
LedgerKind::FailedAttempt,
"tried: route DriftClips into Scheduler directly",
None,
Some(
r#"{"tried":"direct routing","because":"Scheduler only accepts precompiled ScheduledEvent arrays"}"#,
),
);
append_thinking(
&engine,
"sym_sync",
LedgerKind::Hypothesis,
"speculative guess that maybe works",
Some(0.1),
None,
);
}
fn run_prepare_change(db: &Path, args: &[&str]) -> serde_json::Value {
let mut cmd = Command::new(asd_bin());
cmd.arg("--db").arg(db).arg("prepare-change");
for a in args {
cmd.arg(a);
}
let out = cmd.output().expect("spawn");
assert!(
out.status.success(),
"prepare-change exited non-zero\nstderr: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"non-JSON stdout: {e}\n{}",
String::from_utf8_lossy(&out.stdout)
)
})
}
#[test]
fn cli_surfaces_all_four_thinking_kinds() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
seed_drift_pad_world(&db);
let v = run_prepare_change(&db, &["syncDriftScheduler"]);
let pt = &v["prior_thinking"];
assert!(
pt.is_object(),
"prior_thinking must be present and an object; got: {pt:#?}"
);
assert!(pt.get("hypotheses").is_some(), "hypotheses arm: {pt:#?}");
assert!(
pt.get("mental_models").is_some(),
"mental_models arm: {pt:#?}"
);
assert!(
pt.get("open_questions").is_some(),
"open_questions arm: {pt:#?}"
);
assert!(
pt.get("failed_attempts").is_some(),
"failed_attempts arm: {pt:#?}"
);
}
#[test]
fn cli_always_emits_thinking_summary_with_correct_counts() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
seed_drift_pad_world(&db);
let v = run_prepare_change(&db, &["syncDriftScheduler"]);
let s = &v["thinking_summary"];
assert!(s.is_object(), "thinking_summary must always emit: {v:#?}");
assert_eq!(s["surfaced"].as_u64(), Some(4), "summary: {s:#?}");
assert_eq!(
s["by_kind_dropped"]["hypothesis"].as_u64(),
Some(1),
"by_kind_dropped must record the low-conf hypothesis: {s:#?}"
);
assert!(
s["matched_for_query"].as_u64().unwrap_or(0) >= 1,
"must report at least one matched qname: {s:#?}"
);
}
#[test]
fn cli_thinking_floor_flag_lowers_threshold() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
seed_drift_pad_world(&db);
let v = run_prepare_change(&db, &["syncDriftScheduler", "--thinking-floor", "0.05"]);
let hyps = v["prior_thinking"]["hypotheses"]
.as_array()
.expect("hypotheses array");
assert_eq!(
hyps.len(),
2,
"with floor 0.05 both hypotheses must surface: {hyps:#?}"
);
let dropped = &v["thinking_summary"]["by_kind_dropped"];
let hyp_dropped = dropped
.get("hypothesis")
.and_then(|v| v.as_u64())
.unwrap_or(0);
assert_eq!(
hyp_dropped, 0,
"no hypotheses dropped at floor 0.05 (absent OR explicit 0 both fine; got: {dropped:#?})"
);
}
#[test]
fn cli_emits_workspace_count_only_when_query_matched_nothing() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
seed_drift_pad_world(&db);
let v = run_prepare_change(&db, &["completelyunrelatednonsensequery"]);
let s = &v["thinking_summary"];
if s["matched_for_query"].as_u64() == Some(0) {
assert!(
s["entries_in_workspace"].as_u64().unwrap_or(0) >= 1,
"must populate workspace count when query matched nothing; got: {s:#?}"
);
}
}