返回 CodeWhale
session.rs
根目录 / crates / core / src / 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
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
138 lines RUST