Skip to main content

quorum_rs/cli/
thread.rs

1//! Client-owned thread store.
2//!
3//! A thread is the interactive client's durable record of a conversation:
4//! a stable id, a subject, the policy currently acting as the "model", and the
5//! ordered messages. The client owns this transcript (standard Chat Completions
6//! semantics), so restoring a conversation does not depend on the orchestrator's
7//! history retention. See
8//! `docs/explanation/policy-as-model-and-threads.md`.
9//!
10//! This is deliberately separate from [`crate::agents::session_store`], which
11//! maps Claude-CLI transcript UUIDs and holds no conversation content.
12//!
13//! Layout: one JSON file per thread under `~/.nsed/threads/{id}.json`
14//! (`$NSED_THREAD_DIR` overrides the directory). one-file-per-thread keeps
15//! listing cheap and avoids whole-store rewrite races between concurrent
16//! clients.
17
18use std::path::PathBuf;
19
20use chrono::Utc;
21use serde::{Deserialize, Serialize};
22
23/// One message in a conversation. `policy_id` records which policy (the "model")
24/// produced an assistant message, so a thread that swapped policy mid-thread stays
25/// self-describing; `job_id` links the turn back to its deliberation.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Message {
28    /// Stable node id. Tree edges use it (`parent_id`). Empty on pre-tree JSON;
29    /// [`Thread::migrate_linear`] backfills it on load.
30    #[serde(default)]
31    pub id: String,
32    /// Parent node in the thread tree; `None` for a root turn. A new turn roots
33    /// under the message the user replied to (the cursor node).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub parent_id: Option<String>,
36    /// Branch identity = the per-branch `conversation_id`. A reply to a leaf
37    /// inherits its parent's branch (linear continuation → session resume); a
38    /// reply under a non-leaf node gets a fresh branch (a fork → new session).
39    #[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    /// A root turn stamped with the current wall-clock time and a fresh id +
52    /// branch. Use [`Thread::reply`] to attach it under a parent (which sets
53    /// `parent_id` and resolves `branch_id` from the fork rule).
54    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/// A stored conversation. `server_thread` is the `x-nsed-session-id` used for
69/// cheap same-policy continuation; it is an optimisation, not the source of
70/// truth — the source of truth is `messages`.
71#[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    /// Job id of a launched turn whose reply hasn't landed yet. Persisted so a
84    /// deliberation that finishes while the TUI is closed can be reconciled on
85    /// reopen (fetch the result by this id). Cleared when the reply is appended.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub pending_job: Option<String>,
88    /// Unsent compose text (pastes already expanded), persisted so a long draft
89    /// survives a restart / a failed send instead of being lost with the TUI
90    /// process. Restored into the compose box on reopen; cleared once sent.
91    #[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    /// A fresh thread with a generated id and the current timestamps.
99    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    /// Append a message and bump `updated`. An unrooted turn (`parent_id`
116    /// unset) continues the current tip — linear-append, the common case — so
117    /// callers that don't care about branching keep working. Use [`Self::reply`]
118    /// to root a turn under a specific node (or as a new root).
119    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    // --- tree ops -----------------------------------------------------------
131
132    /// Look up a message by id.
133    pub fn get(&self, id: &str) -> Option<&Message> {
134        self.messages.iter().find(|m| m.id == id)
135    }
136
137    /// Direct replies to `parent_id` (`None` = root turns).
138    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    /// A message with no replies — the tip of its branch.
146    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    /// How many forks lie in a node's ancestry — its indent depth in the
154    /// reader. A linear lineage shares one branch (depth 0); each fork along the
155    /// path introduces a new branch (+1). = distinct `branch_id`s on the path − 1.
156    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    /// Root→node path following `parent_id`. Empty if `id` is unknown.
165    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            // Guard against a corrupt parent cycle — stop before looping forever.
171            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    /// The newest message overall — the default reply target ("continue the
182    /// conversation"). `None` on an empty thread. Ties on `ts` (same-second
183    /// turns) resolve to the last appended: `max_by_key` returns the last of
184    /// equal maxima, and messages are pushed in order.
185    pub fn tip(&self) -> Option<&Message> {
186        self.messages.iter().max_by_key(|m| m.ts)
187    }
188
189    /// Attach a new turn under `parent_id` and return its id. Branch rule: a
190    /// reply to a current leaf inherits the parent's branch (linear
191    /// continuation); a reply under a non-leaf node (or a root turn) starts a
192    /// fresh branch (a fork). The pre-append leaf check is what makes the FIRST
193    /// reply continue and a SECOND reply to the same node fork.
194    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        // Raw push — `reply` sets `parent_id` explicitly (incl. `None` for a
210        // root), so bypass `push_message`'s linear-append auto-link.
211        self.updated = m.ts.max(self.updated);
212        self.messages.push(m);
213        id
214    }
215
216    /// Roll back the most recently added turn — used when the orchestrator
217    /// rejects a just-sent turn (e.g. no agents online) so the thread doesn't
218    /// keep a phantom "deliberating" turn with no job behind it. Removes the last
219    /// message only if it's a childless `user` turn (the optimistic send) and
220    /// clears `pending_job`. Returns whether a turn was removed.
221    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        // Never drop a turn that already has a reply/child hanging off it.
230        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    /// Backfill tree fields on a pre-tree (linear) thread: assign ids, chain
243    /// each message under the previous one, and share a single branch. A no-op
244    /// once every message already carries an id.
245    pub fn migrate_linear(&mut self) {
246        // Already tree-shaped — nothing to backfill.
247        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        // Only a FULLY pre-tree thread (every id empty) gets a fresh linear
256        // parent chain. If some messages already carry ids, the thread is (or
257        // was) tree-shaped — backfill missing fields but never rewrite existing
258        // parent edges, or we'd flatten real branches.
259        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    /// Render the root→`parent_id` path plus a new user message into one task
276    /// string for the native deliberation API. A fork only carries its own
277    /// lineage (the path), which is what makes "reply under wsup? only" differ
278    /// from "continue after Hi!". Subject leads as framing (see
279    /// [`Self::to_deliberation_query`]).
280    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    /// Render the stored conversation plus a new user message into a single
300    /// task string for the native deliberation API (which takes one
301    /// `user_query`, not a message array). Mirrors the server compat layer's
302    /// `to_query_string` so a multi-turn thread over the native transport reads
303    /// the same way: prior non-empty messages prefixed with `[role]`, the new
304    /// question last. The thread's subject (when set) leads the request as a
305    /// `Subject:` line so the agents see the conversation's framing.
306    pub fn to_deliberation_query(&self, new_user_message: &str) -> String {
307        // Default reply target is the tip; for a linear thread its root-path is
308        // the whole conversation, so this matches the pre-tree behaviour.
309        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/// Filesystem-backed store of threads.
315#[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    /// Resolve the store directory: `$NSED_THREAD_DIR` → `~/.nsed/threads` → a
328    /// user-unique temp dir when no home is set (never a world-shared path,
329    /// since transcripts may hold private content).
330    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    /// A store rooted at an explicit directory (tests only).
348    #[cfg(test)]
349    pub(crate) fn with_dir(dir: std::path::PathBuf) -> Self {
350        Self { dir }
351    }
352
353    /// Path of a thread file, rejecting ids that are not a plain slug so a
354    /// caller-supplied id can never escape the store directory.
355    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    /// Persist a thread as-is, creating the store directory if needed.
367    /// `updated` is owned by the thread ([`Thread::new`] /
368    /// [`Thread::push_message`] maintain it), so saving is a plain write.
369    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    /// Delete a thread's stored file. `true` if it was removed (or already
379    /// gone); `false` on an invalid id or a real filesystem error. The
380    /// transcript is client-owned, so this is the only copy.
381    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    /// Load a thread by id. `None` when it does not exist or the id is invalid.
392    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    /// All threads, newest-updated first. Unreadable/corrupt files are skipped.
401    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    /// The most recently updated thread, for `--continue`.
420    pub fn latest(&self) -> Option<Thread> {
421        self.list().into_iter().next()
422    }
423
424    /// Load thread `id`, append an assistant reply (tagged with the deliberation
425    /// `job_id` and the policy that produced it), and persist. Returns `true`
426    /// on success; `false` if the thread is missing or the write fails. Used to
427    /// record a deliberation's answer once it completes.
428    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        // Idempotent: the live JobComplete path and the reconcile-on-reopen path
439        // can both try to record the same job's reply — record it once.
440        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        // Explicit policy wins; otherwise attribute the reply to the thread's
452        // active policy (the one that produced it).
453        let policy_id = policy
454            .map(str::to_string)
455            .or_else(|| thread.active_policy.clone());
456        // Parent the reply under the tip (the user turn that launched this job),
457        // inheriting its branch so the lineage stays linear.
458        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        // The awaited reply landed — clear the pending marker.
465        if thread.pending_job.as_deref() == Some(job_id) {
466            thread.pending_job = None;
467        }
468        self.save(&thread).is_ok()
469    }
470
471    /// Record which launched job a thread is awaiting a reply from, so a
472    /// deliberation that finishes while the TUI is closed can be reconciled on
473    /// reopen. Persists immediately; `false` if the thread is missing.
474    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    /// Clear the pending-job marker (e.g. after a cancel — the turn stays
483    /// reply-less so a follow-up can continue). `false` if the thread is missing.
484    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
493/// A stable per-user suffix for the no-home temp fallback, so two users on one
494/// host do not share a transcript directory.
495fn 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"); // has a reply → not optimistic
521        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    /// A legacy pre-tree message (no id/parent/branch), as old JSON deserializes.
538    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        // First reply to `hi` (a leaf) → same branch (linear continuation).
557        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        // The whole linear lineage wsup?→Hi!→foo shares one branch (each was a
563        // first reply to a leaf).
564        assert_eq!(
565            t.get(&root).unwrap().branch_id,
566            t.get(&hi).unwrap().branch_id
567        );
568        // Second reply to `hi` (now a non-leaf) → fork, fresh branch distinct
569        // from the linear lineage.
570        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        // Corrupt JSON could carry a cycle a↔b; the walk must terminate.
580        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        // Must return (not hang) and never exceed the node count.
588        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        // A real tree with one stray legacy (empty-id) message must NOT be
596        // flattened — existing parent edges are load-bearing branch structure.
597        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"); // leaf reply → same branch
620        let c = t.reply(Some(&a), "user", "fork1"); // a now non-leaf → fork
621        let d = t.reply(Some(&c), "assistant", "d"); // continues the fork
622        let e = t.reply(Some(&c), "user", "fork2"); // c non-leaf → fork-of-fork
623        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        // A fork rooted at `a` (wsup?) must NOT see the Hi!/foo lineage.
651        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        // One shared branch, linear parent chain.
673        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        // Idempotent.
685        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        // End-to-end branch check: fork under an old node, its deliberation's
707        // reply must record on the fork's branch (its conversation_id lineage),
708        // not the sibling branch.
709        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"); // `a` now non-leaf
714        let uf = t.reply(Some(&a), "user", "fork question"); // fork → fresh branch
715        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        // First append records the reply + clears the pending marker.
795        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        // The live JobComplete + reconcile paths can both fire — record once.
804        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        // A different job's reply must not clear job-A's pending marker.
821        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        // Cancel clears it so a follow-up can send.
840        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(""); // no subject → no prefix, tests flatten only
897        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    // ── deep payload inspection ──────────────────────────────────────
954
955    #[test]
956    fn thread_on_disk_json_schema_is_stable() {
957        // Lock the persisted shape: field names + which are omitted. A change
958        // here silently breaks resume of older threads.
959        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        // orchestrator / server_thread are None → omitted, not null.
976        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    // ── e2e: the full client-side thread turn cycle ──────────────────
1004
1005    #[test]
1006    fn full_thread_turn_cycle_persists_ordered_attributed_transcript() {
1007        // Mirrors the runtime flow without NATS: a thread is created, the user
1008        // message is recorded + persisted on submit, then the deliberation
1009        // reply is appended by the completion path (a separate load/save). The
1010        // reloaded transcript must be ordered and correctly attributed.
1011        let tmp = tempfile::TempDir::new().unwrap();
1012        let store = store_in(tmp.path());
1013
1014        // Turn 1: submit records the user message (as ThreadView::submit does).
1015        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        // Completion appends the reply (as the loop's JobComplete does).
1023        assert!(store.append_reply(&t.id, "first answer", "job-1", None));
1024
1025        // Turn 2: reload (ThreadView::on_enter), submit again.
1026        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        // Final transcript: 4 ordered, attributed messages.
1038        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        // Replies inherit the thread's active policy when none is passed.
1048        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}