use super::*;
use super::fts::events_extract_sql;
#[test]
fn title_reads_current_name_and_is_workspace_fenced() {
let root = tempfile::tempdir().unwrap();
let ws_a = tempfile::tempdir().unwrap();
let ws_b = tempfile::tempdir().unwrap();
let store_a = ConversationStore::new(root.path(), ws_a.path(), 100).unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
let id = store_a.create("mesh docking", None).unwrap();
assert_eq!(store_a.title(&id).unwrap().as_deref(), Some("mesh docking"));
store_a.rename(&id, "docking ceremony").unwrap();
assert_eq!(
store_a.title(&id).unwrap().as_deref(),
Some("docking ceremony")
);
assert_eq!(store_a.title("no-such-conversation").unwrap(), None);
assert_eq!(store_b.title(&id).unwrap(), None);
}
#[test]
fn preference_pin_round_trips_defaults_empty_and_is_workspace_fenced() {
let root = tempfile::tempdir().unwrap();
let ws_a = tempfile::tempdir().unwrap();
let ws_b = tempfile::tempdir().unwrap();
let store_a = ConversationStore::new(root.path(), ws_a.path(), 100).unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
let id = store_a.create("pinned work", None).unwrap();
let fresh = store_a.preference_pin(&id).unwrap().expect("row exists");
assert!(fresh.is_empty(), "fresh row must read as nothing pinned");
let pin = crate::OperatorPreferencePin {
backend: Some("sol".into()),
model: Some("gpt-5.6-sol".into()),
cognition: Some("off".into()),
tenacity: Some(crate::Tenacity::Relentless),
initiative: Some(crate::Initiative::Decisive),
};
store_a.update_preference_pin(&id, &pin).unwrap();
assert_eq!(store_a.preference_pin(&id).unwrap(), Some(pin.clone()));
assert_eq!(
store_a.preference_pin("no-such-conversation").unwrap(),
None
);
assert_eq!(store_b.preference_pin(&id).unwrap(), None);
assert!(store_b
.update_preference_pin(&id, &crate::OperatorPreferencePin::default())
.is_err());
assert_eq!(store_a.preference_pin(&id).unwrap(), Some(pin));
}
#[test]
fn open_rewrites_an_old_pin_cognition_label_on_every_open() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let open = || ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let raw_pin = |store: &ConversationStore, id: &str| -> String {
store
.lock_conn()
.query_row(
"SELECT preference_pin FROM conversations WHERE id = ?1",
[id],
|row| row.get(0),
)
.unwrap()
};
let (old, garbled) = {
let store = open();
let old = store.create("old vocabulary", None).unwrap();
let garbled = store.create("garbled", None).unwrap();
store
.set_raw_preference_pin_for_test(
&old,
r#"{"backend":"sol","cognition":"contemplating","tenacity":"relentless"}"#,
)
.unwrap();
store
.set_raw_preference_pin_for_test(&garbled, "not json")
.unwrap();
(old, garbled)
};
let store = open();
assert_eq!(
raw_pin(&store, &old),
r#"{"backend":"sol","cognition":"meticulous","tenacity":"relentless"}"#
);
let pin = store.preference_pin(&old).unwrap().unwrap();
assert_eq!(pin.cognition.as_deref(), Some("meticulous"));
assert_eq!(
raw_pin(&store, &garbled),
"not json",
"left for the strict decode"
);
store
.set_raw_preference_pin_for_test(&old, r#"{"cognition":"pondering"}"#)
.unwrap();
drop(store);
assert_eq!(raw_pin(&open(), &old), r#"{"cognition":"rational"}"#);
}
#[test]
fn open_splits_an_old_pin_tenacity_label_on_every_open() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let open = || ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let (standard, kept, garbled) = {
let store = open();
let ids: Vec<String> = ["standard", "kept", "garbled"]
.iter()
.map(|t| store.create(t, None).unwrap())
.collect();
store
.set_raw_preference_pin_for_test(&ids[2], "not json")
.unwrap();
(ids[0].clone(), ids[1].clone(), ids[2].clone())
};
{
let store = open();
store
.set_raw_preference_pin_for_test(
&standard,
r#"{"backend":"sol","tenacity":"standard"}"#,
)
.unwrap();
store
.set_raw_preference_pin_for_test(
&kept,
r#"{"tenacity":"insistent","initiative":"patient"}"#,
)
.unwrap();
}
let store = open();
let pin = store.preference_pin(&standard).unwrap().unwrap();
assert_eq!(
pin.backend.as_deref(),
Some("sol"),
"the rest of the pin survives"
);
assert_eq!(pin.tenacity, Some(crate::Tenacity::Normal));
assert_eq!(pin.initiative, Some(crate::Initiative::Measured));
let pin = store.preference_pin(&kept).unwrap().unwrap();
assert_eq!(
(pin.tenacity, pin.initiative),
(
Some(crate::Tenacity::Normal),
Some(crate::Initiative::Patient)
),
"an initiative the row already carries wins"
);
assert!(
store.preference_pin(&garbled).is_err(),
"left for the strict decode"
);
}
#[test]
fn open_gives_an_old_relentless_pin_eager_initiative_once() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let open = || ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let raw = r#"{"tenacity":"relentless"}"#;
let (old, with_initiative) = {
let store = open();
let old = store.create("old relentless", None).unwrap();
let with_initiative = store.create("already split", None).unwrap();
store.set_raw_preference_pin_for_test(&old, raw).unwrap();
store
.set_raw_preference_pin_for_test(
&with_initiative,
r#"{"tenacity":"relentless","initiative":"patient"}"#,
)
.unwrap();
store
.lock_conn()
.execute("DELETE FROM store_migrations", [])
.unwrap();
(old, with_initiative)
};
let store = open();
let pin = store.preference_pin(&old).unwrap().unwrap();
assert_eq!(pin.tenacity, Some(crate::Tenacity::Relentless));
assert_eq!(pin.initiative, Some(crate::Initiative::Eager));
assert_eq!(
store
.preference_pin(&with_initiative)
.unwrap()
.unwrap()
.initiative,
Some(crate::Initiative::Patient)
);
store.set_raw_preference_pin_for_test(&old, raw).unwrap();
drop(store);
let pin = open().preference_pin(&old).unwrap().unwrap();
assert_eq!(pin.initiative, None, "the once-step does not run again");
}
#[test]
fn update_preference_pin_does_not_tick_activity() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let older = store.create("older", None).unwrap();
store.append_turn(&older, "q", "a").unwrap();
let newer = store.create("newer", None).unwrap();
store.append_turn(&newer, "q", "a").unwrap();
let tick_of = |id: &str| -> i64 {
let conn = store.lock_conn();
conn.query_row(
"SELECT activity_tick FROM conversations WHERE id = ?1",
[id],
|row| row.get(0),
)
.unwrap()
};
let before = tick_of(&older);
store
.update_preference_pin(
&older,
&crate::OperatorPreferencePin {
initiative: Some(crate::Initiative::Patient),
..Default::default()
},
)
.unwrap();
assert_eq!(tick_of(&older), before, "posture must not bump the tick");
assert_eq!(store.latest_open().unwrap().unwrap().id, newer);
}
#[test]
fn older_database_gains_the_preference_pin_column_on_open() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let id;
{
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
id = store.create("pre-1668 conversation", None).unwrap();
let conn = store.lock_conn();
conn.execute_batch("ALTER TABLE conversations DROP COLUMN preference_pin")
.unwrap();
}
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let pin = reopened.preference_pin(&id).unwrap().expect("row survives");
assert!(pin.is_empty(), "backfill must read as nothing pinned");
}
#[test]
fn corrupt_preference_pin_column_refuses_to_load_garbage() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let id = store.create("garbled", None).unwrap();
{
let conn = store.lock_conn();
conn.execute(
"UPDATE conversations SET preference_pin = 'not json' WHERE id = ?1",
[&id],
)
.unwrap();
}
let err = store.preference_pin(&id).unwrap_err().to_string();
assert!(err.contains("refusing to load garbage"), "{err}");
}
#[test]
fn a_tampered_preference_pin_column_cannot_carry_authority_state() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let id = store.create("tampered", None).unwrap();
for hostile in [
r#"{"backend":"sol","api_key":"sk-evil"}"#,
r#"{"backend":"sol","endpoint":"http://evil.example:9"}"#,
r#"{"caveats":{"fs":"unrestricted"}}"#,
r#"{"sandbox":"off","permissions":["all"]}"#,
r#"{"ocap":["fs:/"],"cognition":"off"}"#,
] {
store.set_raw_preference_pin_for_test(&id, hostile).unwrap();
let err = store
.preference_pin(&id)
.expect_err(&format!("must refuse: {hostile}"))
.to_string();
assert!(err.contains("refusing to load garbage"), "{err}");
}
let honest = crate::OperatorPreferencePin {
backend: Some("sol".into()),
..Default::default()
};
store.update_preference_pin(&id, &honest).unwrap();
assert_eq!(store.preference_pin(&id).unwrap(), Some(honest));
}
#[test]
fn prompt_receipts_do_not_backfill_historical_turns() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conv = store.create("historical", None).unwrap();
store
.append_turn(&conv, "old user text", "old answer")
.unwrap();
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
assert!(reopened.prompt_chain(&conv).unwrap().is_empty());
assert!(reopened.latest_prompt(&conv).unwrap().is_none());
}
#[test]
fn opening_v1_prompt_schema_adds_authority_column_without_backfill_guessing() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let conv = "v1-authority-migration";
let (a_id, retry_id) = {
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let a = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator("A", "A"),
)
.unwrap();
let b = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::operator_continuation("B", "B", a.submitted().id()),
)
.unwrap();
let retry = store
.begin_prompt(
conv,
"title",
None,
crate::prompt::NewPrompt::harness_retry("retry", "retry", b.submitted().id()),
)
.unwrap();
for receipt in store.prompt_chain(conv).unwrap() {
let legacy = receipt.into_legacy_v1_for_test();
let conn = store.lock_conn();
conn.execute(
"UPDATE prompt_receipts
SET active_operator_id = NULL, receipt_hash = ?2,
encoding_version = 1
WHERE id = ?1",
rusqlite::params![legacy.id().to_string(), legacy.receipt_hash()],
)
.unwrap();
}
{
let conn = store.lock_conn();
conn.execute_batch("ALTER TABLE prompt_receipts DROP COLUMN active_operator_id")
.unwrap();
}
let _ = &b; (a.submitted().id(), retry.submitted().id())
};
let reopened = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let context = reopened
.turn_prompt_context(conv, retry_id)
.unwrap()
.unwrap();
assert_eq!(context.active().id(), a_id);
assert_eq!(context.submitted().receipt().active_operator_id(), None);
let columns: Vec<String> = {
let conn = reopened.lock_conn();
let mut stmt = conn.prepare("PRAGMA table_info(prompt_receipts)").unwrap();
let selected = stmt
.query_map([], |row| row.get(1))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
selected
};
assert!(columns.iter().any(|column| column == "active_operator_id"));
}
#[test]
fn wal_fallback_classifier_matches_known_nfs_failures() {
assert!(wal_fallback_eligible("locking protocol"));
assert!(wal_fallback_eligible("disk I/O error"));
assert!(wal_fallback_eligible(
"sqlite failure: `Error code 15: Locking Protocol`"
));
assert!(!wal_fallback_eligible("no such table: turns"));
assert!(!wal_fallback_eligible("database is locked"));
assert!(!wal_fallback_eligible(""));
}
#[test]
fn wal_mode_pairs_with_synchronous_normal_on_the_stores_connection() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let conn = store.lock_conn();
let sync_level: i64 = conn
.query_row("PRAGMA synchronous", [], |row| row.get(0))
.unwrap();
assert_eq!(sync_level, 1, "WAL must run at synchronous=NORMAL (1)");
}
#[test]
fn end_conversation_hides_row_from_latest_open_but_not_from_list() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let c1 = store.create("first", None).unwrap();
store.append_turn(&c1, "q1", "a1").unwrap();
let c2 = store.create("second", None).unwrap();
store.append_turn(&c2, "q2", "a2").unwrap();
assert_eq!(store.latest_open().unwrap().unwrap().id, c2);
store.end_conversation(&c2, "wq").unwrap();
assert_eq!(
store.latest_open().unwrap().unwrap().id,
c1,
"an ended conversation is skipped on auto-resume"
);
assert_eq!(store.list().unwrap().len(), 2);
assert!(
store
.search("q2", 5)
.unwrap()
.iter()
.any(|h| h.conversation_id == c2),
"ended conversation stays in the FTS index for /recall"
);
store.end_conversation(&c1, "end").unwrap();
assert!(store.latest_open().unwrap().is_none());
assert_eq!(store.list().unwrap().len(), 2, "still listed after ending");
}
#[test]
fn list_all_spans_workspaces_and_carries_their_paths() {
let root = tempfile::tempdir().unwrap();
let ws_a = tempfile::tempdir().unwrap();
let ws_b = tempfile::tempdir().unwrap();
let canon = |d: &std::path::Path| {
std::fs::canonicalize(d)
.unwrap()
.to_string_lossy()
.into_owned()
};
let store_a = ConversationStore::new(root.path(), ws_a.path(), 100).unwrap();
let a = store_a.create("in A", None).unwrap();
store_a.append_turn(&a, "q", "a").unwrap();
let store_b = ConversationStore::new(root.path(), ws_b.path(), 100).unwrap();
let b = store_b.create("in B", None).unwrap();
store_b.append_turn(&b, "q", "a").unwrap();
assert_eq!(store_a.list().unwrap().len(), 1);
assert_eq!(store_b.list().unwrap().len(), 1);
let all = store_a.list_all().unwrap();
assert_eq!(all.len(), 2, "both workspaces' conversations");
let path_of = |id: &str| {
all.iter()
.find(|(s, _)| s.id == id)
.map(|(_, p)| p.clone())
.unwrap()
};
assert_eq!(path_of(&a), canon(ws_a.path()));
assert_eq!(path_of(&b), canon(ws_b.path()));
let follower = ConversationStore::new(root.path(), path_of(&b), 100).unwrap();
assert_eq!(follower.load(&b).unwrap().title, "in B");
}
#[test]
fn end_conversation_does_not_tick_activity_and_is_idempotent() {
let root = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let store = ConversationStore::new(root.path(), workspace.path(), 100).unwrap();
let older = store.create("older", None).unwrap();
store.append_turn(&older, "q", "a").unwrap();
let newer = store.create("newer", None).unwrap();
store.append_turn(&newer, "q", "a").unwrap();
let tick_of = |id: &str| -> i64 {
let conn = store.lock_conn();
conn.query_row(
"SELECT activity_tick FROM conversations WHERE id = ?1",
[id],
|row| row.get(0),
)
.unwrap()
};
let before = tick_of(&older);
store.end_conversation(&older, "new").unwrap();
assert_eq!(tick_of(&older), before, "ending must not bump the tick");
store.end_conversation(&older, "new").unwrap();
assert_eq!(store.latest_open().unwrap().unwrap().id, newer);
}
fn s(raw: &str) -> String {
sanitize_fts5_query(raw).unwrap()
}
#[test]
fn sanitizer_auto_quotes_dotted_hyphenated_and_path_tokens() {
assert_eq!(s("chat-send"), "\"chat-send\"");
assert_eq!(s("P2.2"), "\"P2.2\"");
assert_eq!(s("my-app.config.ts"), "\"my-app.config.ts\"");
assert_eq!(s("src/store.rs"), "\"src/store.rs\"");
assert_eq!(s("tcp:p4d.p4d-ascii:1666"), "\"tcp:p4d.p4d-ascii:1666\"");
assert_eq!(s("issue #246"), "issue \"#246\"");
assert_eq!(s("hello world"), "hello world");
assert_eq!(s("writer_clock"), "writer_clock");
assert_eq!(s("schlüssel wörter"), "schlüssel wörter");
}
#[test]
fn sanitizer_preserves_balanced_phrases_and_drops_dangling_quotes() {
assert_eq!(s("\"exact phrase\" extra"), "\"exact phrase\" extra");
assert_eq!(s("say \"hello world\" now"), "say \"hello world\" now");
assert_eq!(s("foo \"bar"), "foo bar");
assert_eq!(s("\"unclosed"), "unclosed");
assert_eq!(s("\"a b\" \"c"), "\"a b\" c");
assert_eq!(s("\"AND OR\""), "\"AND OR\"");
assert_eq!(s("\"P2.2 chat-send\""), "\"P2.2 chat-send\"");
let err = sanitize_fts5_query("\"\"").unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{err}");
let err = sanitize_fts5_query("\"--\"").unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{err}");
}
#[test]
fn sanitizer_trims_dangling_operators() {
assert_eq!(s("foo AND"), "foo");
assert_eq!(s("OR foo"), "foo");
assert_eq!(s("NOT foo"), "foo");
assert_eq!(s("foo AND AND bar"), "foo AND bar");
assert_eq!(s("foo AND OR bar"), "foo AND bar");
assert_eq!(s("AND foo OR"), "foo");
assert_eq!(s("foo OR bar"), "foo OR bar");
assert_eq!(s("foo NOT bar"), "foo NOT bar");
assert_eq!(s("a OR b OR c"), "a OR b OR c");
assert_eq!(s("foo and bar"), "foo and bar");
let err = sanitize_fts5_query("AND").unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{err}");
assert_eq!(s("NEAR"), "\"NEAR\"");
assert_eq!(s("near"), "near");
}
#[test]
fn sanitizer_strips_metacharacter_injection() {
assert_eq!(s("(foo OR bar) AND baz"), "foo OR bar AND baz");
assert_eq!(s("foo* ^bar"), "foo bar");
assert_eq!(s("col*umn"), "column");
for q in ["\"", "*", "^", "( )", "*^()"] {
let err = sanitize_fts5_query(q).unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
}
assert_eq!(s("fo\"o bar"), "fo o bar");
assert_eq!(s("?? foo !!"), "foo");
assert_eq!(s("foo \u{a0} "), "foo"); }
#[test]
fn sanitizer_handles_mixed_phrases_terms_and_operators() {
assert_eq!(
s("\"tuning writeback\" OR coverage-floor"),
"\"tuning writeback\" OR \"coverage-floor\""
);
assert_eq!(
s("error \"chain violation\" NOT P2.2"),
"error \"chain violation\" NOT \"P2.2\""
);
assert_eq!(s("AND \"lead phrase\" tail"), "\"lead phrase\" tail");
}
#[test]
fn sanitizer_errors_on_empty_and_whitespace_queries() {
for q in ["", " ", "\t\n"] {
let err = sanitize_fts5_query(q).unwrap_err().to_string();
assert!(err.contains("reduced to nothing"), "{q:?}: {err}");
}
}
#[test]
fn events_extract_sql_guards_and_targets_the_seam_keys() {
let sql = events_extract_sql("new.events", "tool");
assert!(sql.contains("json_valid(new.events)"));
assert!(sql.contains("json_each(new.events)"));
assert!(sql.contains("'$.tool'"));
assert!(sql.contains("ELSE '' END"));
}