Skip to main content

codewhale_core/
session.rs

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
18use std::path::PathBuf;
19
20use serde::{Deserialize, Serialize};
21
22use crate::ids::{SessionId, ThreadId};
23use 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)]
30pub 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
47impl 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)]
76pub 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
87impl 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]
108pub 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)]
113mod 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}