use std::path::PathBuf;
use chrono::Utc;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
#[serde(default)]
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default)]
pub branch_id: String,
pub role: String,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
pub ts: i64,
}
impl Message {
pub fn now(role: impl Into<String>, content: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4().simple().to_string(),
parent_id: None,
branch_id: uuid::Uuid::new_v4().simple().to_string(),
role: role.into(),
content: content.into(),
policy_id: None,
job_id: None,
ts: Utc::now().timestamp(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Thread {
pub id: String,
pub subject: String,
pub created: i64,
pub updated: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub orchestrator: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_thread: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_job: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft: Option<String>,
#[serde(default)]
pub messages: Vec<Message>,
}
impl Thread {
pub fn new(subject: impl Into<String>) -> Self {
let now = Utc::now().timestamp();
Self {
id: format!("thread-{}", uuid::Uuid::new_v4().simple()),
subject: subject.into(),
created: now,
updated: now,
active_policy: None,
orchestrator: None,
server_thread: None,
pending_job: None,
draft: None,
messages: Vec::new(),
}
}
pub fn push_message(&mut self, mut turn: Message) {
if turn.parent_id.is_none()
&& let Some(prev) = self.messages.last()
{
turn.parent_id = Some(prev.id.clone());
turn.branch_id = prev.branch_id.clone();
}
self.updated = turn.ts.max(self.updated);
self.messages.push(turn);
}
pub fn get(&self, id: &str) -> Option<&Message> {
self.messages.iter().find(|m| m.id == id)
}
pub fn children(&self, parent_id: Option<&str>) -> Vec<&Message> {
self.messages
.iter()
.filter(|m| m.parent_id.as_deref() == parent_id)
.collect()
}
pub fn is_leaf(&self, id: &str) -> bool {
!self
.messages
.iter()
.any(|m| m.parent_id.as_deref() == Some(id))
}
pub fn fork_depth(&self, id: &str) -> usize {
let mut seen = std::collections::HashSet::new();
for m in self.path_to_root(id) {
seen.insert(m.branch_id.clone());
}
seen.len().saturating_sub(1)
}
pub fn path_to_root(&self, id: &str) -> Vec<&Message> {
let mut path = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut cur = self.get(id);
while let Some(m) = cur {
if !seen.insert(m.id.as_str()) {
break;
}
path.push(m);
cur = m.parent_id.as_deref().and_then(|p| self.get(p));
}
path.reverse();
path
}
pub fn tip(&self) -> Option<&Message> {
self.messages.iter().max_by_key(|m| m.ts)
}
pub fn reply(
&mut self,
parent_id: Option<&str>,
role: impl Into<String>,
content: impl Into<String>,
) -> String {
let mut m = Message::now(role, content);
m.parent_id = parent_id.map(|s| s.to_string());
if let Some(pid) = parent_id
&& self.is_leaf(pid)
&& let Some(parent) = self.get(pid)
{
m.branch_id = parent.branch_id.clone();
}
let id = m.id.clone();
self.updated = m.ts.max(self.updated);
self.messages.push(m);
id
}
pub fn rollback_last_user_turn(&mut self) -> bool {
let Some(last) = self.messages.last() else {
return false;
};
if last.role != "user" {
return false;
}
let last_id = last.id.clone();
if self
.messages
.iter()
.any(|m| m.parent_id.as_deref() == Some(last_id.as_str()))
{
return false;
}
self.messages.pop();
self.pending_job = None;
true
}
pub fn migrate_linear(&mut self) {
if self
.messages
.iter()
.all(|m| !m.id.is_empty() && !m.branch_id.is_empty())
{
return;
}
let branch = uuid::Uuid::new_v4().simple().to_string();
let fully_legacy = self.messages.iter().all(|m| m.id.is_empty());
let mut prev: Option<String> = None;
for m in &mut self.messages {
if m.id.is_empty() {
m.id = uuid::Uuid::new_v4().simple().to_string();
}
if m.branch_id.is_empty() {
m.branch_id = branch.clone();
}
if fully_legacy {
m.parent_id = prev.clone();
prev = Some(m.id.clone());
}
}
}
pub fn to_deliberation_query_from(
&self,
parent_id: Option<&str>,
new_user_message: &str,
) -> String {
let path = parent_id.map(|p| self.path_to_root(p)).unwrap_or_default();
let pairs = path
.iter()
.map(|m| (m.role.as_str(), m.content.as_str()))
.chain(std::iter::once(("user", new_user_message)));
let body = crate::conversation::flatten_conversation(pairs);
let subject = self.subject.trim();
if subject.is_empty() {
body
} else {
format!("Subject: {subject}\n\n{body}")
}
}
pub fn to_deliberation_query(&self, new_user_message: &str) -> String {
let tip = self.tip().map(|m| m.id.clone());
self.to_deliberation_query_from(tip.as_deref(), new_user_message)
}
}
#[derive(Debug, Clone)]
pub struct ThreadStore {
dir: PathBuf,
}
impl Default for ThreadStore {
fn default() -> Self {
Self::new()
}
}
impl ThreadStore {
pub fn new() -> Self {
if let Ok(explicit) = std::env::var("NSED_THREAD_DIR")
&& !explicit.is_empty()
{
return Self {
dir: PathBuf::from(explicit),
};
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return Self {
dir: PathBuf::from(home).join(".nsed").join("threads"),
};
}
let dir = std::env::temp_dir().join(format!("nsed-threads-{}", user_suffix()));
Self { dir }
}
#[cfg(test)]
pub(crate) fn with_dir(dir: std::path::PathBuf) -> Self {
Self { dir }
}
fn path_for(&self, id: &str) -> Option<PathBuf> {
if id.is_empty()
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return None;
}
Some(self.dir.join(format!("{id}.json")))
}
pub fn save(&self, thread: &Thread) -> std::io::Result<()> {
let path = self.path_for(&thread.id).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid thread id")
})?;
std::fs::create_dir_all(&self.dir)?;
let json = serde_json::to_vec_pretty(thread)?;
std::fs::write(path, json)
}
pub fn delete(&self, id: &str) -> bool {
let Some(path) = self.path_for(id) else {
return false;
};
match std::fs::remove_file(&path) {
Ok(()) => true,
Err(e) => e.kind() == std::io::ErrorKind::NotFound,
}
}
pub fn load(&self, id: &str) -> Option<Thread> {
let path = self.path_for(id)?;
let bytes = std::fs::read(path).ok()?;
let mut thread: Thread = serde_json::from_slice(&bytes).ok()?;
thread.migrate_linear();
Some(thread)
}
pub fn list(&self) -> Vec<Thread> {
let mut out: Vec<Thread> = match std::fs::read_dir(&self.dir) {
Ok(rd) => rd
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| std::fs::read(e.path()).ok())
.filter_map(|b| serde_json::from_slice::<Thread>(&b).ok())
.map(|mut t| {
t.migrate_linear();
t
})
.collect(),
Err(_) => Vec::new(),
};
out.sort_by_key(|s| std::cmp::Reverse(s.updated));
out
}
pub fn latest(&self) -> Option<Thread> {
self.list().into_iter().next()
}
pub fn append_reply(
&self,
id: &str,
content: &str,
job_id: &str,
policy: Option<&str>,
) -> bool {
let Some(mut thread) = self.load(id) else {
return false;
};
if thread
.messages
.iter()
.any(|m| m.job_id.as_deref() == Some(job_id))
{
if thread.pending_job.as_deref() == Some(job_id) {
thread.pending_job = None;
let _ = self.save(&thread);
}
return true;
}
let policy_id = policy
.map(str::to_string)
.or_else(|| thread.active_policy.clone());
let tip = thread.tip().map(|m| m.id.clone());
let reply_id = thread.reply(tip.as_deref(), "assistant", content);
if let Some(m) = thread.messages.iter_mut().find(|m| m.id == reply_id) {
m.job_id = Some(job_id.to_string());
m.policy_id = policy_id;
}
if thread.pending_job.as_deref() == Some(job_id) {
thread.pending_job = None;
}
self.save(&thread).is_ok()
}
pub fn set_pending_job(&self, id: &str, job_id: &str) -> bool {
let Some(mut thread) = self.load(id) else {
return false;
};
thread.pending_job = Some(job_id.to_string());
self.save(&thread).is_ok()
}
pub fn clear_pending_job(&self, id: &str) -> bool {
let Some(mut thread) = self.load(id) else {
return false;
};
thread.pending_job = None;
self.save(&thread).is_ok()
}
}
fn user_suffix() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn rollback_removes_a_childless_user_turn() {
let mut t = Thread::new("s");
t.reply(None, "user", "q");
t.pending_job = Some("job-x".into());
assert!(t.rollback_last_user_turn());
assert!(t.messages.is_empty(), "the optimistic turn is gone");
assert!(t.pending_job.is_none(), "phantom pending job cleared");
}
#[test]
fn rollback_keeps_an_answered_turn() {
let mut t = Thread::new("s");
let uid = t.reply(None, "user", "q");
t.reply(Some(&uid), "assistant", "a"); assert!(!t.rollback_last_user_turn());
assert_eq!(t.messages.len(), 2);
}
#[test]
fn rollback_is_a_noop_on_an_empty_thread() {
let mut t = Thread::new("s");
assert!(!t.rollback_last_user_turn());
}
fn store_in(dir: &Path) -> ThreadStore {
ThreadStore {
dir: dir.to_path_buf(),
}
}
fn legacy(role: &str, content: &str, ts: i64) -> Message {
Message {
id: String::new(),
parent_id: None,
branch_id: String::new(),
role: role.into(),
content: content.into(),
policy_id: None,
job_id: None,
ts,
}
}
#[test]
fn reply_to_leaf_continues_branch_second_reply_forks() {
let mut t = Thread::new("s");
let root = t.reply(None, "user", "wsup?");
let hi = t.reply(Some(&root), "assistant", "Hi!");
let foo = t.reply(Some(&hi), "user", "foo");
assert_eq!(
t.get(&foo).unwrap().branch_id,
t.get(&hi).unwrap().branch_id
);
assert_eq!(
t.get(&root).unwrap().branch_id,
t.get(&hi).unwrap().branch_id
);
let fork = t.reply(Some(&hi), "user", "other");
assert_ne!(
t.get(&fork).unwrap().branch_id,
t.get(&hi).unwrap().branch_id
);
}
#[test]
fn path_to_root_stops_on_a_parent_cycle() {
let mut t = Thread::new("s");
let mut a = Message::now("user", "a");
let mut b = Message::now("user", "b");
a.parent_id = Some(b.id.clone());
b.parent_id = Some(a.id.clone());
let (aid, bid) = (a.id.clone(), b.id.clone());
t.messages = vec![a, b];
assert!(t.path_to_root(&aid).len() <= 2);
assert!(t.path_to_root(&bid).len() <= 2);
assert!(t.fork_depth(&aid) <= 2);
}
#[test]
fn migrate_preserves_existing_tree_edges_on_partial_legacy() {
let mut t = Thread::new("s");
let r = t.reply(None, "user", "root");
let c = t.reply(Some(&r), "user", "child");
t.messages.push(legacy("user", "orphan", 9));
t.migrate_linear();
assert_eq!(
t.get(&c).unwrap().parent_id.as_deref(),
Some(r.as_str()),
"existing edge preserved"
);
assert!(
t.messages
.iter()
.all(|m| !m.id.is_empty() && !m.branch_id.is_empty()),
"legacy message backfilled"
);
}
#[test]
fn fork_depth_counts_forks_in_ancestry() {
let mut t = Thread::new("s");
let a = t.reply(None, "user", "root");
let b = t.reply(Some(&a), "assistant", "hi"); let c = t.reply(Some(&a), "user", "fork1"); let d = t.reply(Some(&c), "assistant", "d"); let e = t.reply(Some(&c), "user", "fork2"); assert_eq!(t.fork_depth(&a), 0);
assert_eq!(t.fork_depth(&b), 0);
assert_eq!(t.fork_depth(&c), 1);
assert_eq!(t.fork_depth(&d), 1);
assert_eq!(t.fork_depth(&e), 2);
}
#[test]
fn path_to_root_is_root_first() {
let mut t = Thread::new("s");
let a = t.reply(None, "user", "wsup?");
let b = t.reply(Some(&a), "assistant", "Hi!");
let c = t.reply(Some(&b), "user", "foo");
let path: Vec<_> = t
.path_to_root(&c)
.iter()
.map(|m| m.content.clone())
.collect();
assert_eq!(path, vec!["wsup?", "Hi!", "foo"]);
}
#[test]
fn fork_query_carries_only_its_lineage() {
let mut t = Thread::new("chat");
let a = t.reply(None, "user", "wsup?");
let b = t.reply(Some(&a), "assistant", "Hi!");
let _foo = t.reply(Some(&b), "user", "foo");
let q = t.to_deliberation_query_from(Some(&a), "new rooted from wsup");
assert!(q.contains("wsup?"));
assert!(q.contains("new rooted from wsup"));
assert!(
!q.contains("Hi!"),
"fork must not carry the sibling branch: {q}"
);
assert!(!q.contains("foo"));
assert!(q.contains("Subject: chat"));
}
#[test]
fn migrate_linear_backfills_ids_and_chain() {
let mut t = Thread::new("s");
t.messages = vec![
legacy("user", "q1", 1),
legacy("assistant", "a1", 2),
legacy("user", "q2", 3),
];
t.migrate_linear();
assert!(t.messages.iter().all(|m| !m.id.is_empty()));
let branch = &t.messages[0].branch_id;
assert!(t.messages.iter().all(|m| &m.branch_id == branch));
assert_eq!(t.messages[0].parent_id, None);
assert_eq!(
t.messages[1].parent_id.as_deref(),
Some(t.messages[0].id.as_str())
);
assert_eq!(
t.messages[2].parent_id.as_deref(),
Some(t.messages[1].id.as_str())
);
let before = t.messages.clone();
t.migrate_linear();
assert_eq!(t.messages, before);
}
#[test]
fn append_reply_parents_to_tip_and_inherits_branch() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut t = Thread::new("t");
let u = t.reply(None, "user", "q");
store.save(&t).unwrap();
assert!(store.append_reply(&t.id, "answer", "job-1", None));
let got = store.load(&t.id).unwrap();
let reply = got.messages.iter().find(|m| m.role == "assistant").unwrap();
assert_eq!(reply.parent_id.as_deref(), Some(u.as_str()));
assert_eq!(reply.branch_id, got.get(&u).unwrap().branch_id);
}
#[test]
fn append_reply_after_fork_lands_on_the_fork_branch() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut t = Thread::new("t");
let a = t.reply(None, "user", "root");
let _b = t.reply(Some(&a), "assistant", "hi"); let uf = t.reply(Some(&a), "user", "fork question"); let fork_branch = t.get(&uf).unwrap().branch_id.clone();
assert_ne!(
fork_branch,
t.get(&a).unwrap().branch_id,
"the fork got its own branch"
);
store.save(&t).unwrap();
assert!(store.append_reply(&t.id, "fork answer", "job-fork", None));
let got = store.load(&t.id).unwrap();
let reply = got
.messages
.iter()
.find(|m| m.content == "fork answer")
.unwrap();
assert_eq!(
reply.parent_id.as_deref(),
Some(uf.as_str()),
"under the fork turn"
);
assert_eq!(reply.branch_id, fork_branch, "on the fork branch");
}
#[test]
fn delete_removes_the_thread_file() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let t = Thread::new("x");
store.save(&t).unwrap();
assert!(store.load(&t.id).is_some());
assert!(store.delete(&t.id));
assert!(store.load(&t.id).is_none());
assert!(store.delete(&t.id), "idempotent on already-gone");
assert!(!store.delete(""), "invalid id rejected");
}
#[test]
fn save_then_load_round_trips() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut s = Thread::new("first thread");
s.active_policy = Some("nsed:review".into());
s.push_message(Message::now("user", "what is rust?"));
store.save(&s).unwrap();
let got = store.load(&s.id).expect("loads");
assert_eq!(got.id, s.id);
assert_eq!(got.subject, "first thread");
assert_eq!(got.active_policy.as_deref(), Some("nsed:review"));
assert_eq!(got.messages.len(), 1);
assert_eq!(got.messages[0].content, "what is rust?");
}
#[test]
fn append_reply_adds_assistant_message() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut t = Thread::new("t");
t.push_message(Message::now("user", "q"));
store.save(&t).unwrap();
assert!(store.append_reply(&t.id, "the answer", "job-42", Some("nsed:review")));
let got = store.load(&t.id).unwrap();
assert_eq!(got.messages.len(), 2);
assert_eq!(got.messages[1].role, "assistant");
assert_eq!(got.messages[1].content, "the answer");
assert_eq!(got.messages[1].job_id.as_deref(), Some("job-42"));
assert_eq!(got.messages[1].policy_id.as_deref(), Some("nsed:review"));
}
#[test]
fn append_reply_clears_pending_and_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut t = Thread::new("t");
t.push_message(Message::now("user", "q"));
t.pending_job = Some("job-7".into());
store.save(&t).unwrap();
assert!(store.append_reply(&t.id, "answer", "job-7", None));
let got = store.load(&t.id).unwrap();
assert_eq!(got.messages.len(), 2);
assert!(
got.pending_job.is_none(),
"pending cleared once the reply lands"
);
assert!(store.append_reply(&t.id, "answer", "job-7", None));
assert_eq!(
store.load(&t.id).unwrap().messages.len(),
2,
"same job's reply is not duplicated"
);
}
#[test]
fn append_reply_keeps_pending_for_a_different_job() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut t = Thread::new("t");
t.push_message(Message::now("user", "q"));
t.pending_job = Some("job-A".into());
store.save(&t).unwrap();
assert!(store.append_reply(&t.id, "answer", "job-B", None));
assert_eq!(
store.load(&t.id).unwrap().pending_job.as_deref(),
Some("job-A")
);
}
#[test]
fn set_pending_job_persists() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let t = Thread::new("t");
store.save(&t).unwrap();
assert!(store.set_pending_job(&t.id, "job-9"));
assert_eq!(
store.load(&t.id).unwrap().pending_job.as_deref(),
Some("job-9")
);
assert!(store.clear_pending_job(&t.id));
assert!(store.load(&t.id).unwrap().pending_job.is_none());
}
#[test]
fn append_reply_missing_thread_is_false() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(!store_in(tmp.path()).append_reply("thread-nope", "x", "job-1", None));
}
#[test]
fn load_missing_is_none() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(store_in(tmp.path()).load("thread-nope").is_none());
}
#[test]
fn list_is_newest_updated_first() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut a = Thread::new("a");
a.updated = 100;
let mut b = Thread::new("b");
b.updated = 200;
store.save(&a).unwrap();
store.save(&b).unwrap();
let ids: Vec<String> = store.list().into_iter().map(|s| s.id).collect();
assert_eq!(ids, vec![b.id.clone(), a.id.clone()]);
}
#[test]
fn latest_returns_most_recent() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut older = Thread::new("older");
older.updated = 100;
store.save(&older).unwrap();
let mut newer = Thread::new("newer");
newer.updated = 200;
store.save(&newer).unwrap();
assert_eq!(store.latest().map(|s| s.id), Some(newer.id));
}
#[test]
fn push_message_bumps_updated_and_appends() {
let mut s = Thread::new("x");
let base = s.updated;
let mut t = Message::now("assistant", "hi");
t.ts = base + 500;
s.push_message(t);
assert_eq!(s.messages.len(), 1);
assert_eq!(s.updated, base + 500);
}
#[test]
fn to_deliberation_query_first_turn_is_bare() {
let s = Thread::new(""); assert_eq!(s.to_deliberation_query("hello?"), "hello?");
}
#[test]
fn to_deliberation_query_leads_with_subject() {
let s = Thread::new("Q3 audit");
assert_eq!(
s.to_deliberation_query("what's the risk?"),
"Subject: Q3 audit\n\nwhat's the risk?"
);
}
#[test]
fn to_deliberation_query_multi_turn_prefixes_roles() {
let mut s = Thread::new("");
s.push_message(Message::now("user", "what is rust?"));
s.push_message(Message::now("assistant", "a systems language"));
let q = s.to_deliberation_query("how does it compare to go?");
assert_eq!(
q,
"[user] what is rust?\n\n[assistant] a systems language\n\n[user] how does it compare to go?"
);
}
#[test]
fn to_deliberation_query_skips_empty_turns() {
let mut s = Thread::new("");
s.push_message(Message::now("assistant", " "));
s.push_message(Message::now("user", "real question"));
let q = s.to_deliberation_query("follow up");
assert_eq!(q, "[user] real question\n\n[user] follow up");
}
#[test]
fn path_traversal_ids_are_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
assert!(store.path_for("../escape").is_none());
assert!(store.path_for("a/b").is_none());
assert!(store.path_for("").is_none());
assert!(store.path_for("thread-abc_123").is_some());
}
#[test]
fn list_skips_corrupt_files() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
std::fs::create_dir_all(tmp.path()).unwrap();
std::fs::write(tmp.path().join("broken.json"), b"{not json").unwrap();
let good = Thread::new("good");
store.save(&good).unwrap();
let list = store.list();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, good.id);
}
#[test]
fn thread_on_disk_json_schema_is_stable() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut t = Thread::new("Subject line");
t.active_policy = Some("nsed:review".into());
let mut m = Message::now("assistant", "hi");
m.job_id = Some("job-1".into());
m.policy_id = Some("nsed:review".into());
t.push_message(m);
store.save(&t).unwrap();
let raw = std::fs::read_to_string(tmp.path().join(format!("{}.json", t.id))).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(v["id"], t.id);
assert_eq!(v["subject"], "Subject line");
assert_eq!(v["active_policy"], "nsed:review");
assert!(v["created"].is_number() && v["updated"].is_number());
assert!(v.get("orchestrator").is_none());
assert!(v.get("server_thread").is_none());
let msg = &v["messages"][0];
assert_eq!(msg["role"], "assistant");
assert_eq!(msg["content"], "hi");
assert_eq!(msg["job_id"], "job-1");
assert_eq!(msg["policy_id"], "nsed:review");
assert!(msg["ts"].is_number());
}
#[test]
fn message_omits_none_policy_and_job_id_in_json() {
let m = Message::now("user", "q");
let v: serde_json::Value = serde_json::to_value(&m).unwrap();
assert!(v.get("policy_id").is_none());
assert!(v.get("job_id").is_none());
assert_eq!(v["role"], "user");
}
#[test]
fn to_deliberation_query_preserves_multiline_content() {
let mut t = Thread::new("");
t.push_message(Message::now("user", "line1\nline2"));
let q = t.to_deliberation_query("next");
assert_eq!(q, "[user] line1\nline2\n\n[user] next");
}
#[test]
fn full_thread_turn_cycle_persists_ordered_attributed_transcript() {
let tmp = tempfile::TempDir::new().unwrap();
let store = store_in(tmp.path());
let mut t = Thread::new("audit");
t.active_policy = Some("nsed:audit".into());
let query_1 = t.to_deliberation_query("first question");
assert_eq!(query_1, "Subject: audit\n\nfirst question");
t.push_message(Message::now("user", "first question"));
store.save(&t).unwrap();
assert!(store.append_reply(&t.id, "first answer", "job-1", None));
let mut t = store.load(&t.id).unwrap();
assert_eq!(t.messages.len(), 2);
let query_2 = t.to_deliberation_query("second question");
assert_eq!(
query_2,
"Subject: audit\n\n[user] first question\n\n[assistant] first answer\n\n[user] second question"
);
t.push_message(Message::now("user", "second question"));
store.save(&t).unwrap();
assert!(store.append_reply(&t.id, "second answer", "job-2", None));
let final_thread = store.load(&t.id).unwrap();
let roles: Vec<&str> = final_thread
.messages
.iter()
.map(|m| m.role.as_str())
.collect();
assert_eq!(roles, vec!["user", "assistant", "user", "assistant"]);
assert_eq!(final_thread.messages[1].job_id.as_deref(), Some("job-1"));
assert_eq!(final_thread.messages[3].job_id.as_deref(), Some("job-2"));
assert_eq!(
final_thread.messages[1].policy_id.as_deref(),
Some("nsed:audit")
);
assert_eq!(
final_thread.messages[3].policy_id.as_deref(),
Some("nsed:audit")
);
assert!(final_thread.updated >= final_thread.created);
}
}