1use std::path::PathBuf;
19
20use chrono::Utc;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Message {
28 #[serde(default)]
31 pub id: String,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub parent_id: Option<String>,
36 #[serde(default)]
40 pub branch_id: String,
41 pub role: String,
42 pub content: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub policy_id: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub job_id: Option<String>,
47 pub ts: i64,
48}
49
50impl Message {
51 pub fn now(role: impl Into<String>, content: impl Into<String>) -> Self {
55 Self {
56 id: uuid::Uuid::new_v4().simple().to_string(),
57 parent_id: None,
58 branch_id: uuid::Uuid::new_v4().simple().to_string(),
59 role: role.into(),
60 content: content.into(),
61 policy_id: None,
62 job_id: None,
63 ts: Utc::now().timestamp(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct Thread {
73 pub id: String,
74 pub subject: String,
75 pub created: i64,
76 pub updated: i64,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub active_policy: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub orchestrator: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub server_thread: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub pending_job: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub draft: Option<String>,
93 #[serde(default)]
94 pub messages: Vec<Message>,
95}
96
97impl Thread {
98 pub fn new(subject: impl Into<String>) -> Self {
100 let now = Utc::now().timestamp();
101 Self {
102 id: format!("thread-{}", uuid::Uuid::new_v4().simple()),
103 subject: subject.into(),
104 created: now,
105 updated: now,
106 active_policy: None,
107 orchestrator: None,
108 server_thread: None,
109 pending_job: None,
110 draft: None,
111 messages: Vec::new(),
112 }
113 }
114
115 pub fn push_message(&mut self, mut turn: Message) {
120 if turn.parent_id.is_none()
121 && let Some(prev) = self.messages.last()
122 {
123 turn.parent_id = Some(prev.id.clone());
124 turn.branch_id = prev.branch_id.clone();
125 }
126 self.updated = turn.ts.max(self.updated);
127 self.messages.push(turn);
128 }
129
130 pub fn get(&self, id: &str) -> Option<&Message> {
134 self.messages.iter().find(|m| m.id == id)
135 }
136
137 pub fn children(&self, parent_id: Option<&str>) -> Vec<&Message> {
139 self.messages
140 .iter()
141 .filter(|m| m.parent_id.as_deref() == parent_id)
142 .collect()
143 }
144
145 pub fn is_leaf(&self, id: &str) -> bool {
147 !self
148 .messages
149 .iter()
150 .any(|m| m.parent_id.as_deref() == Some(id))
151 }
152
153 pub fn fork_depth(&self, id: &str) -> usize {
157 let mut seen = std::collections::HashSet::new();
158 for m in self.path_to_root(id) {
159 seen.insert(m.branch_id.clone());
160 }
161 seen.len().saturating_sub(1)
162 }
163
164 pub fn path_to_root(&self, id: &str) -> Vec<&Message> {
166 let mut path = Vec::new();
167 let mut seen = std::collections::HashSet::new();
168 let mut cur = self.get(id);
169 while let Some(m) = cur {
170 if !seen.insert(m.id.as_str()) {
172 break;
173 }
174 path.push(m);
175 cur = m.parent_id.as_deref().and_then(|p| self.get(p));
176 }
177 path.reverse();
178 path
179 }
180
181 pub fn tip(&self) -> Option<&Message> {
186 self.messages.iter().max_by_key(|m| m.ts)
187 }
188
189 pub fn reply(
195 &mut self,
196 parent_id: Option<&str>,
197 role: impl Into<String>,
198 content: impl Into<String>,
199 ) -> String {
200 let mut m = Message::now(role, content);
201 m.parent_id = parent_id.map(|s| s.to_string());
202 if let Some(pid) = parent_id
203 && self.is_leaf(pid)
204 && let Some(parent) = self.get(pid)
205 {
206 m.branch_id = parent.branch_id.clone();
207 }
208 let id = m.id.clone();
209 self.updated = m.ts.max(self.updated);
212 self.messages.push(m);
213 id
214 }
215
216 pub fn rollback_last_user_turn(&mut self) -> bool {
222 let Some(last) = self.messages.last() else {
223 return false;
224 };
225 if last.role != "user" {
226 return false;
227 }
228 let last_id = last.id.clone();
229 if self
231 .messages
232 .iter()
233 .any(|m| m.parent_id.as_deref() == Some(last_id.as_str()))
234 {
235 return false;
236 }
237 self.messages.pop();
238 self.pending_job = None;
239 true
240 }
241
242 pub fn migrate_linear(&mut self) {
246 if self
248 .messages
249 .iter()
250 .all(|m| !m.id.is_empty() && !m.branch_id.is_empty())
251 {
252 return;
253 }
254 let branch = uuid::Uuid::new_v4().simple().to_string();
255 let fully_legacy = self.messages.iter().all(|m| m.id.is_empty());
260 let mut prev: Option<String> = None;
261 for m in &mut self.messages {
262 if m.id.is_empty() {
263 m.id = uuid::Uuid::new_v4().simple().to_string();
264 }
265 if m.branch_id.is_empty() {
266 m.branch_id = branch.clone();
267 }
268 if fully_legacy {
269 m.parent_id = prev.clone();
270 prev = Some(m.id.clone());
271 }
272 }
273 }
274
275 pub fn to_deliberation_query_from(
281 &self,
282 parent_id: Option<&str>,
283 new_user_message: &str,
284 ) -> String {
285 let path = parent_id.map(|p| self.path_to_root(p)).unwrap_or_default();
286 let pairs = path
287 .iter()
288 .map(|m| (m.role.as_str(), m.content.as_str()))
289 .chain(std::iter::once(("user", new_user_message)));
290 let body = crate::conversation::flatten_conversation(pairs);
291 let subject = self.subject.trim();
292 if subject.is_empty() {
293 body
294 } else {
295 format!("Subject: {subject}\n\n{body}")
296 }
297 }
298
299 pub fn to_deliberation_query(&self, new_user_message: &str) -> String {
307 let tip = self.tip().map(|m| m.id.clone());
310 self.to_deliberation_query_from(tip.as_deref(), new_user_message)
311 }
312}
313
314#[derive(Debug, Clone)]
316pub struct ThreadStore {
317 dir: PathBuf,
318}
319
320impl Default for ThreadStore {
321 fn default() -> Self {
322 Self::new()
323 }
324}
325
326impl ThreadStore {
327 pub fn new() -> Self {
331 if let Ok(explicit) = std::env::var("NSED_THREAD_DIR")
332 && !explicit.is_empty()
333 {
334 return Self {
335 dir: PathBuf::from(explicit),
336 };
337 }
338 if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
339 return Self {
340 dir: PathBuf::from(home).join(".nsed").join("threads"),
341 };
342 }
343 let dir = std::env::temp_dir().join(format!("nsed-threads-{}", user_suffix()));
344 Self { dir }
345 }
346
347 #[cfg(test)]
349 pub(crate) fn with_dir(dir: std::path::PathBuf) -> Self {
350 Self { dir }
351 }
352
353 fn path_for(&self, id: &str) -> Option<PathBuf> {
356 if id.is_empty()
357 || !id
358 .chars()
359 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
360 {
361 return None;
362 }
363 Some(self.dir.join(format!("{id}.json")))
364 }
365
366 pub fn save(&self, thread: &Thread) -> std::io::Result<()> {
370 let path = self.path_for(&thread.id).ok_or_else(|| {
371 std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid thread id")
372 })?;
373 std::fs::create_dir_all(&self.dir)?;
374 let json = serde_json::to_vec_pretty(thread)?;
375 std::fs::write(path, json)
376 }
377
378 pub fn delete(&self, id: &str) -> bool {
382 let Some(path) = self.path_for(id) else {
383 return false;
384 };
385 match std::fs::remove_file(&path) {
386 Ok(()) => true,
387 Err(e) => e.kind() == std::io::ErrorKind::NotFound,
388 }
389 }
390
391 pub fn load(&self, id: &str) -> Option<Thread> {
393 let path = self.path_for(id)?;
394 let bytes = std::fs::read(path).ok()?;
395 let mut thread: Thread = serde_json::from_slice(&bytes).ok()?;
396 thread.migrate_linear();
397 Some(thread)
398 }
399
400 pub fn list(&self) -> Vec<Thread> {
402 let mut out: Vec<Thread> = match std::fs::read_dir(&self.dir) {
403 Ok(rd) => rd
404 .flatten()
405 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
406 .filter_map(|e| std::fs::read(e.path()).ok())
407 .filter_map(|b| serde_json::from_slice::<Thread>(&b).ok())
408 .map(|mut t| {
409 t.migrate_linear();
410 t
411 })
412 .collect(),
413 Err(_) => Vec::new(),
414 };
415 out.sort_by_key(|s| std::cmp::Reverse(s.updated));
416 out
417 }
418
419 pub fn latest(&self) -> Option<Thread> {
421 self.list().into_iter().next()
422 }
423
424 pub fn append_reply(
429 &self,
430 id: &str,
431 content: &str,
432 job_id: &str,
433 policy: Option<&str>,
434 ) -> bool {
435 let Some(mut thread) = self.load(id) else {
436 return false;
437 };
438 if thread
441 .messages
442 .iter()
443 .any(|m| m.job_id.as_deref() == Some(job_id))
444 {
445 if thread.pending_job.as_deref() == Some(job_id) {
446 thread.pending_job = None;
447 let _ = self.save(&thread);
448 }
449 return true;
450 }
451 let policy_id = policy
454 .map(str::to_string)
455 .or_else(|| thread.active_policy.clone());
456 let tip = thread.tip().map(|m| m.id.clone());
459 let reply_id = thread.reply(tip.as_deref(), "assistant", content);
460 if let Some(m) = thread.messages.iter_mut().find(|m| m.id == reply_id) {
461 m.job_id = Some(job_id.to_string());
462 m.policy_id = policy_id;
463 }
464 if thread.pending_job.as_deref() == Some(job_id) {
466 thread.pending_job = None;
467 }
468 self.save(&thread).is_ok()
469 }
470
471 pub fn set_pending_job(&self, id: &str, job_id: &str) -> bool {
475 let Some(mut thread) = self.load(id) else {
476 return false;
477 };
478 thread.pending_job = Some(job_id.to_string());
479 self.save(&thread).is_ok()
480 }
481
482 pub fn clear_pending_job(&self, id: &str) -> bool {
485 let Some(mut thread) = self.load(id) else {
486 return false;
487 };
488 thread.pending_job = None;
489 self.save(&thread).is_ok()
490 }
491}
492
493fn user_suffix() -> String {
496 std::env::var("USER")
497 .or_else(|_| std::env::var("USERNAME"))
498 .unwrap_or_else(|_| "unknown".to_string())
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use std::path::Path;
505
506 #[test]
507 fn rollback_removes_a_childless_user_turn() {
508 let mut t = Thread::new("s");
509 t.reply(None, "user", "q");
510 t.pending_job = Some("job-x".into());
511 assert!(t.rollback_last_user_turn());
512 assert!(t.messages.is_empty(), "the optimistic turn is gone");
513 assert!(t.pending_job.is_none(), "phantom pending job cleared");
514 }
515
516 #[test]
517 fn rollback_keeps_an_answered_turn() {
518 let mut t = Thread::new("s");
519 let uid = t.reply(None, "user", "q");
520 t.reply(Some(&uid), "assistant", "a"); assert!(!t.rollback_last_user_turn());
522 assert_eq!(t.messages.len(), 2);
523 }
524
525 #[test]
526 fn rollback_is_a_noop_on_an_empty_thread() {
527 let mut t = Thread::new("s");
528 assert!(!t.rollback_last_user_turn());
529 }
530
531 fn store_in(dir: &Path) -> ThreadStore {
532 ThreadStore {
533 dir: dir.to_path_buf(),
534 }
535 }
536
537 fn legacy(role: &str, content: &str, ts: i64) -> Message {
539 Message {
540 id: String::new(),
541 parent_id: None,
542 branch_id: String::new(),
543 role: role.into(),
544 content: content.into(),
545 policy_id: None,
546 job_id: None,
547 ts,
548 }
549 }
550
551 #[test]
552 fn reply_to_leaf_continues_branch_second_reply_forks() {
553 let mut t = Thread::new("s");
554 let root = t.reply(None, "user", "wsup?");
555 let hi = t.reply(Some(&root), "assistant", "Hi!");
556 let foo = t.reply(Some(&hi), "user", "foo");
558 assert_eq!(
559 t.get(&foo).unwrap().branch_id,
560 t.get(&hi).unwrap().branch_id
561 );
562 assert_eq!(
565 t.get(&root).unwrap().branch_id,
566 t.get(&hi).unwrap().branch_id
567 );
568 let fork = t.reply(Some(&hi), "user", "other");
571 assert_ne!(
572 t.get(&fork).unwrap().branch_id,
573 t.get(&hi).unwrap().branch_id
574 );
575 }
576
577 #[test]
578 fn path_to_root_stops_on_a_parent_cycle() {
579 let mut t = Thread::new("s");
581 let mut a = Message::now("user", "a");
582 let mut b = Message::now("user", "b");
583 a.parent_id = Some(b.id.clone());
584 b.parent_id = Some(a.id.clone());
585 let (aid, bid) = (a.id.clone(), b.id.clone());
586 t.messages = vec![a, b];
587 assert!(t.path_to_root(&aid).len() <= 2);
589 assert!(t.path_to_root(&bid).len() <= 2);
590 assert!(t.fork_depth(&aid) <= 2);
591 }
592
593 #[test]
594 fn migrate_preserves_existing_tree_edges_on_partial_legacy() {
595 let mut t = Thread::new("s");
598 let r = t.reply(None, "user", "root");
599 let c = t.reply(Some(&r), "user", "child");
600 t.messages.push(legacy("user", "orphan", 9));
601 t.migrate_linear();
602 assert_eq!(
603 t.get(&c).unwrap().parent_id.as_deref(),
604 Some(r.as_str()),
605 "existing edge preserved"
606 );
607 assert!(
608 t.messages
609 .iter()
610 .all(|m| !m.id.is_empty() && !m.branch_id.is_empty()),
611 "legacy message backfilled"
612 );
613 }
614
615 #[test]
616 fn fork_depth_counts_forks_in_ancestry() {
617 let mut t = Thread::new("s");
618 let a = t.reply(None, "user", "root");
619 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);
624 assert_eq!(t.fork_depth(&b), 0);
625 assert_eq!(t.fork_depth(&c), 1);
626 assert_eq!(t.fork_depth(&d), 1);
627 assert_eq!(t.fork_depth(&e), 2);
628 }
629
630 #[test]
631 fn path_to_root_is_root_first() {
632 let mut t = Thread::new("s");
633 let a = t.reply(None, "user", "wsup?");
634 let b = t.reply(Some(&a), "assistant", "Hi!");
635 let c = t.reply(Some(&b), "user", "foo");
636 let path: Vec<_> = t
637 .path_to_root(&c)
638 .iter()
639 .map(|m| m.content.clone())
640 .collect();
641 assert_eq!(path, vec!["wsup?", "Hi!", "foo"]);
642 }
643
644 #[test]
645 fn fork_query_carries_only_its_lineage() {
646 let mut t = Thread::new("chat");
647 let a = t.reply(None, "user", "wsup?");
648 let b = t.reply(Some(&a), "assistant", "Hi!");
649 let _foo = t.reply(Some(&b), "user", "foo");
650 let q = t.to_deliberation_query_from(Some(&a), "new rooted from wsup");
652 assert!(q.contains("wsup?"));
653 assert!(q.contains("new rooted from wsup"));
654 assert!(
655 !q.contains("Hi!"),
656 "fork must not carry the sibling branch: {q}"
657 );
658 assert!(!q.contains("foo"));
659 assert!(q.contains("Subject: chat"));
660 }
661
662 #[test]
663 fn migrate_linear_backfills_ids_and_chain() {
664 let mut t = Thread::new("s");
665 t.messages = vec![
666 legacy("user", "q1", 1),
667 legacy("assistant", "a1", 2),
668 legacy("user", "q2", 3),
669 ];
670 t.migrate_linear();
671 assert!(t.messages.iter().all(|m| !m.id.is_empty()));
672 let branch = &t.messages[0].branch_id;
674 assert!(t.messages.iter().all(|m| &m.branch_id == branch));
675 assert_eq!(t.messages[0].parent_id, None);
676 assert_eq!(
677 t.messages[1].parent_id.as_deref(),
678 Some(t.messages[0].id.as_str())
679 );
680 assert_eq!(
681 t.messages[2].parent_id.as_deref(),
682 Some(t.messages[1].id.as_str())
683 );
684 let before = t.messages.clone();
686 t.migrate_linear();
687 assert_eq!(t.messages, before);
688 }
689
690 #[test]
691 fn append_reply_parents_to_tip_and_inherits_branch() {
692 let tmp = tempfile::TempDir::new().unwrap();
693 let store = store_in(tmp.path());
694 let mut t = Thread::new("t");
695 let u = t.reply(None, "user", "q");
696 store.save(&t).unwrap();
697 assert!(store.append_reply(&t.id, "answer", "job-1", None));
698 let got = store.load(&t.id).unwrap();
699 let reply = got.messages.iter().find(|m| m.role == "assistant").unwrap();
700 assert_eq!(reply.parent_id.as_deref(), Some(u.as_str()));
701 assert_eq!(reply.branch_id, got.get(&u).unwrap().branch_id);
702 }
703
704 #[test]
705 fn append_reply_after_fork_lands_on_the_fork_branch() {
706 let tmp = tempfile::TempDir::new().unwrap();
710 let store = store_in(tmp.path());
711 let mut t = Thread::new("t");
712 let a = t.reply(None, "user", "root");
713 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();
716 assert_ne!(
717 fork_branch,
718 t.get(&a).unwrap().branch_id,
719 "the fork got its own branch"
720 );
721 store.save(&t).unwrap();
722
723 assert!(store.append_reply(&t.id, "fork answer", "job-fork", None));
724 let got = store.load(&t.id).unwrap();
725 let reply = got
726 .messages
727 .iter()
728 .find(|m| m.content == "fork answer")
729 .unwrap();
730 assert_eq!(
731 reply.parent_id.as_deref(),
732 Some(uf.as_str()),
733 "under the fork turn"
734 );
735 assert_eq!(reply.branch_id, fork_branch, "on the fork branch");
736 }
737
738 #[test]
739 fn delete_removes_the_thread_file() {
740 let tmp = tempfile::TempDir::new().unwrap();
741 let store = store_in(tmp.path());
742 let t = Thread::new("x");
743 store.save(&t).unwrap();
744 assert!(store.load(&t.id).is_some());
745 assert!(store.delete(&t.id));
746 assert!(store.load(&t.id).is_none());
747 assert!(store.delete(&t.id), "idempotent on already-gone");
748 assert!(!store.delete(""), "invalid id rejected");
749 }
750
751 #[test]
752 fn save_then_load_round_trips() {
753 let tmp = tempfile::TempDir::new().unwrap();
754 let store = store_in(tmp.path());
755 let mut s = Thread::new("first thread");
756 s.active_policy = Some("nsed:review".into());
757 s.push_message(Message::now("user", "what is rust?"));
758 store.save(&s).unwrap();
759
760 let got = store.load(&s.id).expect("loads");
761 assert_eq!(got.id, s.id);
762 assert_eq!(got.subject, "first thread");
763 assert_eq!(got.active_policy.as_deref(), Some("nsed:review"));
764 assert_eq!(got.messages.len(), 1);
765 assert_eq!(got.messages[0].content, "what is rust?");
766 }
767
768 #[test]
769 fn append_reply_adds_assistant_message() {
770 let tmp = tempfile::TempDir::new().unwrap();
771 let store = store_in(tmp.path());
772 let mut t = Thread::new("t");
773 t.push_message(Message::now("user", "q"));
774 store.save(&t).unwrap();
775
776 assert!(store.append_reply(&t.id, "the answer", "job-42", Some("nsed:review")));
777 let got = store.load(&t.id).unwrap();
778 assert_eq!(got.messages.len(), 2);
779 assert_eq!(got.messages[1].role, "assistant");
780 assert_eq!(got.messages[1].content, "the answer");
781 assert_eq!(got.messages[1].job_id.as_deref(), Some("job-42"));
782 assert_eq!(got.messages[1].policy_id.as_deref(), Some("nsed:review"));
783 }
784
785 #[test]
786 fn append_reply_clears_pending_and_is_idempotent() {
787 let tmp = tempfile::TempDir::new().unwrap();
788 let store = store_in(tmp.path());
789 let mut t = Thread::new("t");
790 t.push_message(Message::now("user", "q"));
791 t.pending_job = Some("job-7".into());
792 store.save(&t).unwrap();
793
794 assert!(store.append_reply(&t.id, "answer", "job-7", None));
796 let got = store.load(&t.id).unwrap();
797 assert_eq!(got.messages.len(), 2);
798 assert!(
799 got.pending_job.is_none(),
800 "pending cleared once the reply lands"
801 );
802
803 assert!(store.append_reply(&t.id, "answer", "job-7", None));
805 assert_eq!(
806 store.load(&t.id).unwrap().messages.len(),
807 2,
808 "same job's reply is not duplicated"
809 );
810 }
811
812 #[test]
813 fn append_reply_keeps_pending_for_a_different_job() {
814 let tmp = tempfile::TempDir::new().unwrap();
815 let store = store_in(tmp.path());
816 let mut t = Thread::new("t");
817 t.push_message(Message::now("user", "q"));
818 t.pending_job = Some("job-A".into());
819 store.save(&t).unwrap();
820 assert!(store.append_reply(&t.id, "answer", "job-B", None));
822 assert_eq!(
823 store.load(&t.id).unwrap().pending_job.as_deref(),
824 Some("job-A")
825 );
826 }
827
828 #[test]
829 fn set_pending_job_persists() {
830 let tmp = tempfile::TempDir::new().unwrap();
831 let store = store_in(tmp.path());
832 let t = Thread::new("t");
833 store.save(&t).unwrap();
834 assert!(store.set_pending_job(&t.id, "job-9"));
835 assert_eq!(
836 store.load(&t.id).unwrap().pending_job.as_deref(),
837 Some("job-9")
838 );
839 assert!(store.clear_pending_job(&t.id));
841 assert!(store.load(&t.id).unwrap().pending_job.is_none());
842 }
843
844 #[test]
845 fn append_reply_missing_thread_is_false() {
846 let tmp = tempfile::TempDir::new().unwrap();
847 assert!(!store_in(tmp.path()).append_reply("thread-nope", "x", "job-1", None));
848 }
849
850 #[test]
851 fn load_missing_is_none() {
852 let tmp = tempfile::TempDir::new().unwrap();
853 assert!(store_in(tmp.path()).load("thread-nope").is_none());
854 }
855
856 #[test]
857 fn list_is_newest_updated_first() {
858 let tmp = tempfile::TempDir::new().unwrap();
859 let store = store_in(tmp.path());
860 let mut a = Thread::new("a");
861 a.updated = 100;
862 let mut b = Thread::new("b");
863 b.updated = 200;
864 store.save(&a).unwrap();
865 store.save(&b).unwrap();
866 let ids: Vec<String> = store.list().into_iter().map(|s| s.id).collect();
867 assert_eq!(ids, vec![b.id.clone(), a.id.clone()]);
868 }
869
870 #[test]
871 fn latest_returns_most_recent() {
872 let tmp = tempfile::TempDir::new().unwrap();
873 let store = store_in(tmp.path());
874 let mut older = Thread::new("older");
875 older.updated = 100;
876 store.save(&older).unwrap();
877 let mut newer = Thread::new("newer");
878 newer.updated = 200;
879 store.save(&newer).unwrap();
880 assert_eq!(store.latest().map(|s| s.id), Some(newer.id));
881 }
882
883 #[test]
884 fn push_message_bumps_updated_and_appends() {
885 let mut s = Thread::new("x");
886 let base = s.updated;
887 let mut t = Message::now("assistant", "hi");
888 t.ts = base + 500;
889 s.push_message(t);
890 assert_eq!(s.messages.len(), 1);
891 assert_eq!(s.updated, base + 500);
892 }
893
894 #[test]
895 fn to_deliberation_query_first_turn_is_bare() {
896 let s = Thread::new(""); assert_eq!(s.to_deliberation_query("hello?"), "hello?");
898 }
899
900 #[test]
901 fn to_deliberation_query_leads_with_subject() {
902 let s = Thread::new("Q3 audit");
903 assert_eq!(
904 s.to_deliberation_query("what's the risk?"),
905 "Subject: Q3 audit\n\nwhat's the risk?"
906 );
907 }
908
909 #[test]
910 fn to_deliberation_query_multi_turn_prefixes_roles() {
911 let mut s = Thread::new("");
912 s.push_message(Message::now("user", "what is rust?"));
913 s.push_message(Message::now("assistant", "a systems language"));
914 let q = s.to_deliberation_query("how does it compare to go?");
915 assert_eq!(
916 q,
917 "[user] what is rust?\n\n[assistant] a systems language\n\n[user] how does it compare to go?"
918 );
919 }
920
921 #[test]
922 fn to_deliberation_query_skips_empty_turns() {
923 let mut s = Thread::new("");
924 s.push_message(Message::now("assistant", " "));
925 s.push_message(Message::now("user", "real question"));
926 let q = s.to_deliberation_query("follow up");
927 assert_eq!(q, "[user] real question\n\n[user] follow up");
928 }
929
930 #[test]
931 fn path_traversal_ids_are_rejected() {
932 let tmp = tempfile::TempDir::new().unwrap();
933 let store = store_in(tmp.path());
934 assert!(store.path_for("../escape").is_none());
935 assert!(store.path_for("a/b").is_none());
936 assert!(store.path_for("").is_none());
937 assert!(store.path_for("thread-abc_123").is_some());
938 }
939
940 #[test]
941 fn list_skips_corrupt_files() {
942 let tmp = tempfile::TempDir::new().unwrap();
943 let store = store_in(tmp.path());
944 std::fs::create_dir_all(tmp.path()).unwrap();
945 std::fs::write(tmp.path().join("broken.json"), b"{not json").unwrap();
946 let good = Thread::new("good");
947 store.save(&good).unwrap();
948 let list = store.list();
949 assert_eq!(list.len(), 1);
950 assert_eq!(list[0].id, good.id);
951 }
952
953 #[test]
956 fn thread_on_disk_json_schema_is_stable() {
957 let tmp = tempfile::TempDir::new().unwrap();
960 let store = store_in(tmp.path());
961 let mut t = Thread::new("Subject line");
962 t.active_policy = Some("nsed:review".into());
963 let mut m = Message::now("assistant", "hi");
964 m.job_id = Some("job-1".into());
965 m.policy_id = Some("nsed:review".into());
966 t.push_message(m);
967 store.save(&t).unwrap();
968
969 let raw = std::fs::read_to_string(tmp.path().join(format!("{}.json", t.id))).unwrap();
970 let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
971 assert_eq!(v["id"], t.id);
972 assert_eq!(v["subject"], "Subject line");
973 assert_eq!(v["active_policy"], "nsed:review");
974 assert!(v["created"].is_number() && v["updated"].is_number());
975 assert!(v.get("orchestrator").is_none());
977 assert!(v.get("server_thread").is_none());
978 let msg = &v["messages"][0];
979 assert_eq!(msg["role"], "assistant");
980 assert_eq!(msg["content"], "hi");
981 assert_eq!(msg["job_id"], "job-1");
982 assert_eq!(msg["policy_id"], "nsed:review");
983 assert!(msg["ts"].is_number());
984 }
985
986 #[test]
987 fn message_omits_none_policy_and_job_id_in_json() {
988 let m = Message::now("user", "q");
989 let v: serde_json::Value = serde_json::to_value(&m).unwrap();
990 assert!(v.get("policy_id").is_none());
991 assert!(v.get("job_id").is_none());
992 assert_eq!(v["role"], "user");
993 }
994
995 #[test]
996 fn to_deliberation_query_preserves_multiline_content() {
997 let mut t = Thread::new("");
998 t.push_message(Message::now("user", "line1\nline2"));
999 let q = t.to_deliberation_query("next");
1000 assert_eq!(q, "[user] line1\nline2\n\n[user] next");
1001 }
1002
1003 #[test]
1006 fn full_thread_turn_cycle_persists_ordered_attributed_transcript() {
1007 let tmp = tempfile::TempDir::new().unwrap();
1012 let store = store_in(tmp.path());
1013
1014 let mut t = Thread::new("audit");
1016 t.active_policy = Some("nsed:audit".into());
1017 let query_1 = t.to_deliberation_query("first question");
1018 assert_eq!(query_1, "Subject: audit\n\nfirst question");
1019 t.push_message(Message::now("user", "first question"));
1020 store.save(&t).unwrap();
1021
1022 assert!(store.append_reply(&t.id, "first answer", "job-1", None));
1024
1025 let mut t = store.load(&t.id).unwrap();
1027 assert_eq!(t.messages.len(), 2);
1028 let query_2 = t.to_deliberation_query("second question");
1029 assert_eq!(
1030 query_2,
1031 "Subject: audit\n\n[user] first question\n\n[assistant] first answer\n\n[user] second question"
1032 );
1033 t.push_message(Message::now("user", "second question"));
1034 store.save(&t).unwrap();
1035 assert!(store.append_reply(&t.id, "second answer", "job-2", None));
1036
1037 let final_thread = store.load(&t.id).unwrap();
1039 let roles: Vec<&str> = final_thread
1040 .messages
1041 .iter()
1042 .map(|m| m.role.as_str())
1043 .collect();
1044 assert_eq!(roles, vec!["user", "assistant", "user", "assistant"]);
1045 assert_eq!(final_thread.messages[1].job_id.as_deref(), Some("job-1"));
1046 assert_eq!(final_thread.messages[3].job_id.as_deref(), Some("job-2"));
1047 assert_eq!(
1049 final_thread.messages[1].policy_id.as_deref(),
1050 Some("nsed:audit")
1051 );
1052 assert_eq!(
1053 final_thread.messages[3].policy_id.as_deref(),
1054 Some("nsed:audit")
1055 );
1056 assert!(final_thread.updated >= final_thread.created);
1057 }
1058}