Skip to main content

zagens_core/
session.rs

1//! Session state management for the core engine.
2//!
3//! Tracks conversation history, token usage, and session metadata.
4
5use 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/// Session state for the engine.
16#[derive(Debug, Clone)]
17pub struct Session {
18    /// Model being used
19    pub model: String,
20
21    /// Reasoning-effort tier for DeepSeek thinking mode:
22    /// `"off" | "low" | "medium" | "high" | "max"`. `None` lets the provider
23    /// apply its own defaults.
24    pub reasoning_effort: Option<String>,
25    /// Whether the user selected automatic reasoning effort.
26    pub reasoning_effort_auto: bool,
27
28    /// Whether the user selected automatic model routing.
29    pub auto_model: bool,
30
31    /// Workspace directory
32    pub workspace: PathBuf,
33
34    /// System prompt (optional)
35    pub system_prompt: Option<SystemPrompt>,
36    /// Hash of the last assembled stable system prompt. Used to avoid
37    /// replacing `system_prompt` when unchanged.
38    pub last_system_prompt_hash: Option<u64>,
39    /// Persisted summary blocks generated by context compaction.
40    pub compaction_summary_prompt: Option<SystemPrompt>,
41
42    /// Conversation history (API format)
43    pub messages: Vec<Message>,
44
45    /// Total tokens used in this session
46    pub total_usage: SessionUsage,
47
48    /// Whether shell execution is allowed
49    pub allow_shell: bool,
50
51    /// Whether to trust paths outside workspace
52    pub trust_mode: bool,
53
54    /// Whether the current session should auto-approve tool safety checks.
55    pub auto_approve: bool,
56
57    /// Live UI approval policy used to steer the system prompt.
58    pub approval_mode: ApprovalMode,
59
60    /// Notes file path
61    pub notes_path: PathBuf,
62
63    /// MCP config path
64    pub mcp_config_path: PathBuf,
65
66    /// Session ID (for tracking)
67    pub id: String,
68
69    /// Project context loaded from AGENTS.md, etc.
70    pub project_context: Option<ProjectContext>,
71
72    /// Repo-aware working set for context management.
73    pub working_set: WorkingSet,
74
75    /// Number of cycle boundaries crossed in this session (issue #124). The
76    /// active cycle index is `cycle_count + 1` (cycles are 1-based for users).
77    pub cycle_count: u32,
78
79    /// UTC start time of the *current* cycle. Updated when the engine resets
80    /// the conversation buffer. Used by archive headers and the `/cycles`
81    /// command's display.
82    pub current_cycle_started: DateTime<Utc>,
83
84    /// Briefings produced at past cycle boundaries, in chronological order.
85    /// Bounded growth: one entry per cycle, briefing capped at ~3,000 tokens.
86    pub cycle_briefings: Vec<CycleBriefing>,
87
88    /// Provider-reported `usage.input_tokens` from the **most recent API round**
89    /// (overwrite per round — not summed across tool-call rounds). Authoritative
90    /// for “context size at last inference” per DeepSeek API docs.
91    pub last_api_input_tokens: Option<u32>,
92
93    /// Per-session sampling overrides (optional). `None` → provider / model defaults.
94    pub temperature: Option<f32>,
95    pub top_p: Option<f32>,
96    /// Max output tokens for API requests; when unset, model-specific default applies.
97    pub max_output_tokens: Option<u32>,
98}
99
100impl Session {
101    /// Record per-round API usage. Turn totals still sum via `Turn::add_usage`.
102    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/// Cumulative usage statistics for a session.
110#[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    /// Add usage from a turn
123    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    /// Create a new session
137    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        // Load project context from AGENTS.md, CLAUDE.md, etc.
146        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    /// Add a message to the conversation
184    pub fn add_message(&mut self, message: Message) {
185        self.messages.push(message);
186    }
187
188    /// Rebuild the working set from current messages (best effort).
189    pub fn rebuild_working_set(&mut self) {
190        self.working_set
191            .rebuild_from_messages(&self.messages, &self.workspace);
192    }
193}
194
195/// Whether the user selected automatic model routing (`"auto"` label).
196#[must_use]
197pub fn is_auto_model_label(model: &str) -> bool {
198    model.trim().eq_ignore_ascii_case("auto")
199}
200
201/// Apply runtime model selection to session state and a mirrored config field.
202pub 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
208/// Apply runtime thread sync payload (messages, prompts, model, workspace).
209pub 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/// Index of the last `user` role message in API-format history.
234#[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/// Remove the last user message and everything after it (#383 `/edit`).
244#[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}