返回 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 canonical RuntimeThreadManager creates sessions without a TUI and drives
12 //! the same Engine turn loop used by mounted clients. These types preserve the
13 //! shared model, append log, prefix stability and message revision state; the
14 //! compatibility Core Runtime owns only configuration, hooks and job bookkeeping.
15
16 use std::path::PathBuf;
17
18 use serde::{Deserialize, Serialize};
19
20 use crate::ids::{SessionId, ThreadId};
21 use crate::journal::Journal;
22
23 /// Durable thread (the former `Session`). One per conversation, persisted in
24 /// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the
25 /// journal cursor — plus the typed `ThreadId`. All other fields keep their
26 /// persisted JSON shape unchanged.
27 #[derive(Debug, Clone, Serialize, Deserialize)]
28 pub struct Thread {
29 pub thread_id: ThreadId,
30 /// Active branch tip. `None` before the first journal header.
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub leaf_id: Option<String>,
33 /// Journal (append-only). In-memory projection of the persisted
34 /// `threads/turns/items/events` layout is derived root→leaf.
35 #[serde(default)]
36 pub journal: Journal,
37 pub model: String,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub reasoning_effort: Option<String>,
40 pub workspace: PathBuf,
41 #[serde(default)]
42 pub ephemeral: bool,
43 }
44
45 impl Thread {
46 #[must_use]
47 pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
48 Self {
49 thread_id,
50 leaf_id: None,
51 journal: Journal::new(),
52 model: model.into(),
53 reasoning_effort: None,
54 workspace,
55 ephemeral: false,
56 }
57 }
58
59 #[must_use]
60 pub fn leaf_id(&self) -> Option<&str> {
61 self.leaf_id.as_deref()
62 }
63
64 pub fn set_leaf(&mut self, leaf: Option<String>) {
65 self.leaf_id = leaf;
66 }
67 }
68
69 /// Ephemeral session within a thread (one engine lifetime / one turn's
70 /// live posture). The TUI's `EngineHandle` and the headless `exec` both
71 /// hold a `Session` that points at the same `ThreadId` but with different
72 /// `SessionId`s.
73 #[derive(Debug, Clone)]
74 pub struct Session {
75 pub session_id: SessionId,
76 pub thread_id: ThreadId,
77 /// Model for this session's next turn (may differ from thread default).
78 pub model: String,
79 pub workspace: PathBuf,
80 /// Monotonic `messages_revision` for prefix-cache memoization (carried
81 /// from the former `Session::messages_revision`).
82 pub messages_revision: u64,
83 }
84
85 impl Session {
86 #[must_use]
87 pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
88 Self {
89 session_id: SessionId::new(),
90 thread_id,
91 model: model.into(),
92 workspace,
93 messages_revision: 0,
94 }
95 }
96
97 pub fn bump_revision(&mut self) {
98 self.messages_revision = self.messages_revision.wrapping_add(1);
99 }
100 }
101
102 /// Split helper: derive a `Session` from an existing `Thread` without
103 /// cloning the journal. Headless and TUI call the same constructor so
104 /// the request shape stays identical.
105 #[must_use]
106 pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session {
107 Session::new(thread.thread_id.clone(), workspace, thread.model.clone())
108 }
109
110 #[cfg(test)]
111 mod tests {
112 use super::*;
113
114 #[test]
115 fn thread_and_session_ids_are_distinct_scopes() {
116 let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash");
117 let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
118 let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
119 assert_eq!(s1.thread_id, s2.thread_id);
120 assert_ne!(s1.session_id, s2.session_id);
121 }
122
123 #[test]
124 fn leaf_is_moved_not_rewritten() {
125 let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m");
126 let a = t.journal.append("header", serde_json::json!({}));
127 let b = t.journal.append("user", serde_json::json!("b"));
128 t.leaf_id = t.journal.leaf_id.clone();
129 assert_eq!(t.leaf_id.as_deref(), Some(b.as_str()));
130 assert!(t.journal.branch_to(&a));
131 t.leaf_id = t.journal.leaf_id.clone();
132 assert_eq!(t.leaf_id.as_deref(), Some(a.as_str()));
133 assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf
134 }
135 }
136
136 lines RUST