1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
//! Session state management for the core engine.
//!
//! Tracks conversation history, token usage, and session metadata.
use crate::cycle_manager::CycleBriefing;
use crate::models::{Message, SystemPrompt, Usage};
use crate::prefix_cache::PrefixStabilityManager;
use crate::project_context::{ProjectContext, load_project_context_with_parents};
use crate::tui::approval::ApprovalMode;
use crate::working_set::WorkingSet;
use chrono::{DateTime, Utc};
use std::path::PathBuf;
/// Session state for the engine.
#[derive(Debug, Clone)]
pub struct Session {
/// Model being used
pub model: String,
/// Reasoning-effort tier for DeepSeek thinking mode:
/// `"off" | "low" | "medium" | "high" | "max"`. `None` lets the provider
/// apply its own defaults.
pub reasoning_effort: Option<String>,
/// Whether the user selected automatic reasoning effort.
pub reasoning_effort_auto: bool,
/// Whether the user selected automatic model routing.
pub auto_model: bool,
/// Workspace directory
pub workspace: PathBuf,
/// System prompt (optional)
pub system_prompt: Option<SystemPrompt>,
/// Hash of the last assembled stable system prompt. Used to avoid
/// replacing `system_prompt` when unchanged.
pub last_system_prompt_hash: Option<u64>,
/// Persisted summary blocks generated by context compaction.
pub compaction_summary_prompt: Option<SystemPrompt>,
/// Conversation history (API format)
pub messages: Vec<Message>,
/// Total tokens used in this session
pub total_usage: SessionUsage,
/// Whether shell execution is allowed
pub allow_shell: bool,
/// Whether to trust paths outside workspace
pub trust_mode: bool,
/// Whether the current session should auto-approve tool safety checks.
pub auto_approve: bool,
/// Live UI approval policy used to steer the system prompt.
pub approval_mode: ApprovalMode,
/// Notes file path
pub notes_path: PathBuf,
/// MCP config path
pub mcp_config_path: PathBuf,
/// Session ID (for tracking)
pub id: String,
/// Project context loaded from AGENTS.md, etc.
pub project_context: Option<ProjectContext>,
/// Repo-aware working set for context management.
pub working_set: WorkingSet,
/// Number of cycle boundaries crossed in this session (issue #124). The
/// active cycle index is `cycle_count + 1` (cycles are 1-based for users).
pub cycle_count: u32,
/// UTC start time of the *current* cycle. Updated when the engine resets
/// the conversation buffer. Used by archive headers and the `/cycles`
/// command's display.
pub current_cycle_started: DateTime<Utc>,
/// Briefings produced at past cycle boundaries, in chronological order.
/// Bounded growth: one entry per cycle, briefing capped at ~3,000 tokens.
pub cycle_briefings: Vec<CycleBriefing>,
/// Prefix-cache stability monitor (inspired by Reasonix's Pillar 1).
/// Tracks the immutable prefix fingerprint and detects drift across turns.
/// Set during engine construction; None until the first system prompt assembly.
pub prefix_stability: Option<PrefixStabilityManager>,
}
/// Cumulative usage statistics for a session.
#[derive(Debug, Clone, Default)]
#[allow(clippy::struct_field_names)]
pub struct SessionUsage {
pub input_tokens: u64,
pub output_tokens: u64,
/// Cache creation (miss) tokens. `None` when never observed by the API —
/// do NOT display as 0, which would be indistinguishable from "no misses".
pub cache_creation_input_tokens: Option<u64>,
/// Cache read (hit) tokens. `None` when never observed by the API —
/// do NOT display as 0, which would be indistinguishable from "no hits".
pub cache_read_input_tokens: Option<u64>,
}
impl SessionUsage {
/// Add usage from a turn
pub fn add(&mut self, usage: &Usage) {
self.input_tokens += u64::from(usage.input_tokens);
self.output_tokens += u64::from(usage.output_tokens);
if let Some(tokens) = usage.prompt_cache_miss_tokens {
self.cache_creation_input_tokens =
Some(self.cache_creation_input_tokens.unwrap_or(0) + u64::from(tokens));
}
if let Some(tokens) = usage.prompt_cache_hit_tokens {
self.cache_read_input_tokens =
Some(self.cache_read_input_tokens.unwrap_or(0) + u64::from(tokens));
}
}
}
impl Session {
/// Create a new session
pub fn new(
model: String,
workspace: PathBuf,
allow_shell: bool,
trust_mode: bool,
notes_path: PathBuf,
mcp_config_path: PathBuf,
) -> Self {
// Load project context from AGENTS.md, CLAUDE.md, etc.
let project_context = load_project_context_with_parents(&workspace);
let has_context = project_context.has_instructions();
Self {
model,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
workspace,
system_prompt: None,
compaction_summary_prompt: None,
messages: Vec::new(),
total_usage: SessionUsage::default(),
allow_shell,
trust_mode,
auto_approve: false,
approval_mode: ApprovalMode::Suggest,
notes_path,
mcp_config_path,
id: uuid::Uuid::new_v4().to_string(),
project_context: if has_context {
Some(project_context)
} else {
None
},
last_system_prompt_hash: None,
working_set: WorkingSet::default(),
cycle_count: 0,
current_cycle_started: Utc::now(),
cycle_briefings: Vec::new(),
prefix_stability: None,
}
}
/// Add a message to the conversation
pub fn add_message(&mut self, message: Message) {
self.messages.push(message);
}
/// Rebuild the working set from current messages (best effort).
pub fn rebuild_working_set(&mut self) {
self.working_set
.rebuild_from_messages(&self.messages, &self.workspace);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_usage_cache_starts_none() {
let usage = SessionUsage::default();
assert!(usage.cache_creation_input_tokens.is_none());
assert!(usage.cache_read_input_tokens.is_none());
}
#[test]
fn session_usage_cache_remains_none_when_api_omits_cache() {
let mut usage = SessionUsage::default();
let api_usage = Usage {
input_tokens: 100,
output_tokens: 50,
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
};
usage.add(&api_usage);
assert!(usage.cache_creation_input_tokens.is_none());
assert!(usage.cache_read_input_tokens.is_none());
}
#[test]
fn session_usage_cache_accumulates_when_reported() {
let mut usage = SessionUsage::default();
let api_usage = Usage {
input_tokens: 100,
output_tokens: 50,
prompt_cache_hit_tokens: Some(30),
prompt_cache_miss_tokens: Some(70),
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
};
usage.add(&api_usage);
assert_eq!(usage.cache_read_input_tokens, Some(30));
assert_eq!(usage.cache_creation_input_tokens, Some(70));
usage.add(&api_usage);
assert_eq!(usage.cache_read_input_tokens, Some(60));
assert_eq!(usage.cache_creation_input_tokens, Some(140));
}
#[test]
fn session_usage_cache_preserves_explicit_zero() {
let mut usage = SessionUsage::default();
let api_usage = Usage {
input_tokens: 100,
output_tokens: 50,
prompt_cache_hit_tokens: Some(0), // explicit zero from provider
prompt_cache_miss_tokens: Some(1234),
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
};
usage.add(&api_usage);
// 0 is a valid observed value, must NOT be converted to None
assert_eq!(usage.cache_read_input_tokens, Some(0));
assert_eq!(usage.cache_creation_input_tokens, Some(1234));
}
}