1use crate::approval::ApprovalMode;
6use crate::chat::{Message, SystemPrompt};
7use crate::cycle::CycleBriefing;
8use crate::engine::context::extract_compaction_summary_prompt;
9use crate::models::Usage;
10use crate::project_context::{ProjectContext, load_project_context_with_parents};
11use crate::working_set::WorkingSet;
12use chrono::{DateTime, Utc};
13use std::path::PathBuf;
14
15#[derive(Debug, Clone)]
17pub struct Session {
18 pub model: String,
20
21 pub reasoning_effort: Option<String>,
25 pub reasoning_effort_auto: bool,
27
28 pub auto_model: bool,
30
31 pub workspace: PathBuf,
33
34 pub system_prompt: Option<SystemPrompt>,
36 pub last_system_prompt_hash: Option<u64>,
39 pub compaction_summary_prompt: Option<SystemPrompt>,
41
42 pub messages: Vec<Message>,
44
45 pub total_usage: SessionUsage,
47
48 pub allow_shell: bool,
50
51 pub trust_mode: bool,
53
54 pub auto_approve: bool,
56
57 pub approval_mode: ApprovalMode,
59
60 pub notes_path: PathBuf,
62
63 pub mcp_config_path: PathBuf,
65
66 pub id: String,
68
69 pub project_context: Option<ProjectContext>,
71
72 pub working_set: WorkingSet,
74
75 pub cycle_count: u32,
78
79 pub current_cycle_started: DateTime<Utc>,
83
84 pub cycle_briefings: Vec<CycleBriefing>,
87
88 pub last_api_input_tokens: Option<u32>,
92
93 pub temperature: Option<f32>,
95 pub top_p: Option<f32>,
96 pub max_output_tokens: Option<u32>,
98}
99
100impl Session {
101 pub fn record_api_round_usage(&mut self, usage: &Usage) {
103 if usage.input_tokens > 0 {
104 self.last_api_input_tokens = Some(usage.input_tokens);
105 }
106 }
107}
108
109#[derive(Debug, Clone, Default)]
111#[allow(clippy::struct_field_names)]
112pub struct SessionUsage {
113 pub input_tokens: u64,
114 pub output_tokens: u64,
115 #[allow(dead_code)]
116 pub cache_creation_input_tokens: u64,
117 #[allow(dead_code)]
118 pub cache_read_input_tokens: u64,
119}
120
121impl SessionUsage {
122 pub fn add(&mut self, usage: &Usage) {
124 self.input_tokens += u64::from(usage.input_tokens);
125 self.output_tokens += u64::from(usage.output_tokens);
126 if let Some(tokens) = usage.prompt_cache_miss_tokens {
127 self.cache_creation_input_tokens += u64::from(tokens);
128 }
129 if let Some(tokens) = usage.prompt_cache_hit_tokens {
130 self.cache_read_input_tokens += u64::from(tokens);
131 }
132 }
133}
134
135impl Session {
136 pub fn new(
138 model: String,
139 workspace: PathBuf,
140 allow_shell: bool,
141 trust_mode: bool,
142 notes_path: PathBuf,
143 mcp_config_path: PathBuf,
144 ) -> Self {
145 let project_context = load_project_context_with_parents(&workspace);
147 let has_context = project_context.has_instructions();
148
149 Self {
150 model,
151 reasoning_effort: None,
152 reasoning_effort_auto: false,
153 auto_model: false,
154 workspace,
155 system_prompt: None,
156 compaction_summary_prompt: None,
157 messages: Vec::new(),
158 total_usage: SessionUsage::default(),
159 allow_shell,
160 trust_mode,
161 auto_approve: false,
162 approval_mode: ApprovalMode::Suggest,
163 notes_path,
164 mcp_config_path,
165 id: uuid::Uuid::new_v4().to_string(),
166 project_context: if has_context {
167 Some(project_context)
168 } else {
169 None
170 },
171 last_system_prompt_hash: None,
172 working_set: WorkingSet::default(),
173 cycle_count: 0,
174 current_cycle_started: Utc::now(),
175 cycle_briefings: Vec::new(),
176 last_api_input_tokens: None,
177 temperature: None,
178 top_p: None,
179 max_output_tokens: None,
180 }
181 }
182
183 pub fn add_message(&mut self, message: Message) {
185 self.messages.push(message);
186 }
187
188 pub fn rebuild_working_set(&mut self) {
190 self.working_set
191 .rebuild_from_messages(&self.messages, &self.workspace);
192 }
193}
194
195#[must_use]
197pub fn is_auto_model_label(model: &str) -> bool {
198 model.trim().eq_ignore_ascii_case("auto")
199}
200
201pub fn apply_model_selection(session: &mut Session, config_model: &mut String, model: String) {
203 session.auto_model = is_auto_model_label(&model);
204 session.model = model;
205 config_model.clone_from(&session.model);
206}
207
208pub fn apply_sync_session_payload(
210 session: &mut Session,
211 config_workspace: &mut PathBuf,
212 config_model: &mut String,
213 messages: Vec<Message>,
214 system_prompt: Option<SystemPrompt>,
215 model: String,
216 workspace: PathBuf,
217) {
218 session.messages = messages;
219 session.compaction_summary_prompt = extract_compaction_summary_prompt(system_prompt.clone());
220 session.system_prompt = system_prompt;
221 apply_model_selection(session, config_model, model);
222 session.workspace = workspace.clone();
223 *config_workspace = workspace.clone();
224 let ctx = load_project_context_with_parents(&workspace);
225 session.project_context = if ctx.has_instructions() {
226 Some(ctx)
227 } else {
228 None
229 };
230 session.rebuild_working_set();
231}
232
233#[must_use]
235pub fn index_of_last_user_message(messages: &[Message]) -> Option<usize> {
236 messages
237 .iter()
238 .enumerate()
239 .rev()
240 .find_map(|(idx, msg)| (msg.role == "user").then_some(idx))
241}
242
243#[must_use]
245pub fn truncate_before_last_user_message(messages: &mut Vec<Message>) -> bool {
246 index_of_last_user_message(messages).is_some_and(|idx| {
247 messages.truncate(idx);
248 true
249 })
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::chat::ContentBlock;
256 use std::path::PathBuf;
257
258 #[test]
259 fn is_auto_model_label_matches_auto_case_insensitive() {
260 assert!(is_auto_model_label("auto"));
261 assert!(is_auto_model_label(" Auto "));
262 assert!(!is_auto_model_label("deepseek-v4-pro"));
263 }
264
265 #[test]
266 fn apply_model_selection_updates_session_and_config() {
267 let mut session = Session::new(
268 "old".into(),
269 PathBuf::from("/tmp"),
270 false,
271 false,
272 PathBuf::from("/tmp/notes"),
273 PathBuf::from("/tmp/mcp"),
274 );
275 let mut config_model = "old".to_string();
276 apply_model_selection(&mut session, &mut config_model, "auto".into());
277 assert!(session.auto_model);
278 assert_eq!(session.model, "auto");
279 assert_eq!(config_model, "auto");
280
281 apply_model_selection(&mut session, &mut config_model, "deepseek-v4-pro".into());
282 assert!(!session.auto_model);
283 assert_eq!(session.model, "deepseek-v4-pro");
284 assert_eq!(config_model, "deepseek-v4-pro");
285 }
286
287 #[test]
288 fn apply_sync_session_payload_updates_messages_workspace_and_model() {
289 let tmpdir = tempfile::TempDir::new().unwrap();
290 let ws = tmpdir.path().to_path_buf();
291 let mut session = Session::new(
292 "old-model".into(),
293 ws.clone(),
294 false,
295 false,
296 PathBuf::from("/tmp/notes"),
297 PathBuf::from("/tmp/mcp"),
298 );
299 let mut config_workspace = PathBuf::from("/other");
300 let mut config_model = "old-model".to_string();
301 let messages = vec![Message {
302 role: "user".to_string(),
303 content: vec![ContentBlock::Text {
304 text: "hello".into(),
305 cache_control: None,
306 }],
307 }];
308 apply_sync_session_payload(
309 &mut session,
310 &mut config_workspace,
311 &mut config_model,
312 messages.clone(),
313 None,
314 "auto".into(),
315 ws.clone(),
316 );
317 assert_eq!(session.messages.len(), 1);
318 assert_eq!(session.messages[0].role, "user");
319 assert!(session.auto_model);
320 assert_eq!(session.workspace, ws);
321 assert_eq!(config_workspace, ws);
322 assert_eq!(config_model, "auto");
323 }
324
325 fn user_msg(text: &str) -> Message {
326 Message {
327 role: "user".to_string(),
328 content: vec![ContentBlock::Text {
329 text: text.into(),
330 cache_control: None,
331 }],
332 }
333 }
334
335 fn assistant_msg(text: &str) -> Message {
336 Message {
337 role: "assistant".to_string(),
338 content: vec![ContentBlock::Text {
339 text: text.into(),
340 cache_control: None,
341 }],
342 }
343 }
344
345 #[test]
346 fn truncate_before_last_user_message_removes_tail_exchange() {
347 let mut messages = vec![
348 user_msg("first"),
349 assistant_msg("reply"),
350 user_msg("second"),
351 assistant_msg("partial"),
352 ];
353 assert!(truncate_before_last_user_message(&mut messages));
354 assert_eq!(messages.len(), 2);
355 assert_eq!(messages[1].role, "assistant");
356 }
357
358 #[test]
359 fn truncate_before_last_user_message_noop_without_user() {
360 let mut messages = vec![assistant_msg("only assistant")];
361 assert!(!truncate_before_last_user_message(&mut messages));
362 assert_eq!(messages.len(), 1);
363 }
364}