| 1 | //! `Thread` / `Session` split (issue #5261). |
| 2 | //! |
| 3 | //! `codewhale`'s `Session` was really a thread. The new split is: |
| 4 | //! - `Thread` — durable, persisted, owns the append-only `Journal` and the |
| 5 | //! `leafId` cursor. One row in `state.threads`, one directory on disk. |
| 6 | //! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the |
| 7 | //! in-memory `TurnContext` plus the live approval/sandbox posture for this |
| 8 | //! `SessionId`. Many sessions can attach to one thread over time, but only |
| 9 | //! one `Session` drives a turn for a given `ThreadId` at a time. |
| 10 | //! |
| 11 | //! The thread manager (`ThreadManager` in `crate::lib`) already can start a |
| 12 | //! session with no TUI attached (`spawn_thread_with_history`); this file |
| 13 | //! formalizes the types that make that first-class and moves the former |
| 14 | //! `crates/tui/src/core/session.rs` state (model, reasoning_effort, |
| 15 | //! `AppendLog`, `PrefixStabilityManager`, `frozen_prefix`, |
| 16 | //! `messages_revision`) into `crates/core` so both TUI and headless share it. |
| 17 | |
| 18 | use std::path::PathBuf; |
| 19 | |
| 20 | use serde::{Deserialize, Serialize}; |
| 21 | |
| 22 | use crate::ids::{SessionId, ThreadId}; |
| 23 | use crate::journal::Journal; |
| 24 | |
| 25 | /// Durable thread (the former `Session`). One per conversation, persisted in |
| 26 | /// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the |
| 27 | /// journal cursor — plus the typed `ThreadId`. All other fields keep their |
| 28 | /// persisted JSON shape unchanged. |
| 29 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 30 | pub struct Thread { |
| 31 | pub thread_id: ThreadId, |
| 32 | /// Active branch tip. `None` before the first journal header. |
| 33 | #[serde(skip_serializing_if = "Option::is_none")] |
| 34 | pub leaf_id: Option<String>, |
| 35 | /// Journal (append-only). In-memory projection of the persisted |
| 36 | /// `threads/turns/items/events` layout is derived root→leaf. |
| 37 | #[serde(default)] |
| 38 | pub journal: Journal, |
| 39 | pub model: String, |
| 40 | #[serde(skip_serializing_if = "Option::is_none")] |
| 41 | pub reasoning_effort: Option<String>, |
| 42 | pub workspace: PathBuf, |
| 43 | #[serde(default)] |
| 44 | pub ephemeral: bool, |
| 45 | } |
| 46 | |
| 47 | impl Thread { |
| 48 | #[must_use] |
| 49 | pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self { |
| 50 | Self { |
| 51 | thread_id, |
| 52 | leaf_id: None, |
| 53 | journal: Journal::new(), |
| 54 | model: model.into(), |
| 55 | reasoning_effort: None, |
| 56 | workspace, |
| 57 | ephemeral: false, |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | #[must_use] |
| 62 | pub fn leaf_id(&self) -> Option<&str> { |
| 63 | self.leaf_id.as_deref() |
| 64 | } |
| 65 | |
| 66 | pub fn set_leaf(&mut self, leaf: Option<String>) { |
| 67 | self.leaf_id = leaf; |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// Ephemeral session within a thread (one engine lifetime / one turn's |
| 72 | /// live posture). The TUI's `EngineHandle` and the headless `exec` both |
| 73 | /// hold a `Session` that points at the same `ThreadId` but with different |
| 74 | /// `SessionId`s. |
| 75 | #[derive(Debug, Clone)] |
| 76 | pub struct Session { |
| 77 | pub session_id: SessionId, |
| 78 | pub thread_id: ThreadId, |
| 79 | /// Model for this session's next turn (may differ from thread default). |
| 80 | pub model: String, |
| 81 | pub workspace: PathBuf, |
| 82 | /// Monotonic `messages_revision` for prefix-cache memoization (carried |
| 83 | /// from the former `Session::messages_revision`). |
| 84 | pub messages_revision: u64, |
| 85 | } |
| 86 | |
| 87 | impl Session { |
| 88 | #[must_use] |
| 89 | pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self { |
| 90 | Self { |
| 91 | session_id: SessionId::new(), |
| 92 | thread_id, |
| 93 | model: model.into(), |
| 94 | workspace, |
| 95 | messages_revision: 0, |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | pub fn bump_revision(&mut self) { |
| 100 | self.messages_revision = self.messages_revision.wrapping_add(1); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Split helper: derive a `Session` from an existing `Thread` without |
| 105 | /// cloning the journal. Headless and TUI call the same constructor so |
| 106 | /// the request shape stays identical. |
| 107 | #[must_use] |
| 108 | pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session { |
| 109 | Session::new(thread.thread_id.clone(), workspace, thread.model.clone()) |
| 110 | } |
| 111 | |
| 112 | #[cfg(test)] |
| 113 | mod tests { |
| 114 | use super::*; |
| 115 | |
| 116 | #[test] |
| 117 | fn thread_and_session_ids_are_distinct_scopes() { |
| 118 | let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash"); |
| 119 | let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model); |
| 120 | let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model); |
| 121 | assert_eq!(s1.thread_id, s2.thread_id); |
| 122 | assert_ne!(s1.session_id, s2.session_id); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn leaf_is_moved_not_rewritten() { |
| 127 | let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m"); |
| 128 | let a = t.journal.append("header", serde_json::json!({})); |
| 129 | let b = t.journal.append("user", serde_json::json!("b")); |
| 130 | t.leaf_id = t.journal.leaf_id.clone(); |
| 131 | assert_eq!(t.leaf_id.as_deref(), Some(b.as_str())); |
| 132 | assert!(t.journal.branch_to(&a)); |
| 133 | t.leaf_id = t.journal.leaf_id.clone(); |
| 134 | assert_eq!(t.leaf_id.as_deref(), Some(a.as_str())); |
| 135 | assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf |
| 136 | } |
| 137 | } |
| 138 |