Skip to main content

zeph_config/
session.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Session-scoped user experience settings (#3064).
5//!
6//! Configures behaviours that shape the user's experience per session, such as
7//! showing a recap of the previous conversation on resume.
8
9use serde::{Deserialize, Serialize};
10
11use crate::providers::ProviderName;
12
13/// Top-level `[session]` config block.
14#[derive(Debug, Clone, Deserialize, Serialize)]
15#[serde(default)]
16#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
17pub struct SessionConfig {
18    /// Recap-on-resume settings.
19    pub recap: RecapConfig,
20    /// Whether to persist the last-used provider per channel across restarts.
21    ///
22    /// When `true` (the default), the agent stores the active provider name in `SQLite`
23    /// after each `/provider` switch and restores it on the next startup for the same
24    /// `(channel_type, channel_id)` pair.
25    ///
26    /// Set to `false` to always start with the configured primary provider.
27    pub provider_persistence: bool,
28    /// Whether to persist per-session provider override parameters across restarts (#4654).
29    ///
30    /// Currently persists `reasoning_effort` only (Phase 1). Only takes effect when
31    /// `provider_persistence` is also `true` — overrides are meaningless without a persisted
32    /// provider to apply them to. Default: `true`.
33    pub persist_provider_overrides: bool,
34    /// Whether to maintain a durable, replayable JSONL event log per conversation-session
35    /// (spec-068, #5343). Default: `true`.
36    ///
37    /// When `true`, every channel (CLI, TUI, Telegram, ACP) mints a
38    /// [`zeph_common::SessionId`] on first turn and appends
39    /// `SessionEvent`s to `<data_dir>/<session_id>/events.jsonl`. When `false`, only the
40    /// existing `messages` `SQLite` projection is written (pre-#5343 behavior).
41    pub enabled: bool,
42    /// Directory under which per-session event logs are stored (spec-068 §4.1).
43    ///
44    /// Default: `.zeph/sessions` (sibling of `memory.sqlite_path`'s parent directory).
45    pub data_dir: String,
46    /// Opt-in AEAD encryption of session event logs. Deferred to a post-MVP implementation
47    /// (spec-068 §4.3) — currently has no effect. Default: `false`.
48    pub encrypt: bool,
49    /// Event-log size (in MB) that acts as a rotate/condense trigger guard (spec-068 §17).
50    /// Default: `256`.
51    pub max_event_log_mb: u64,
52    /// Durable context condensation settings (spec-068 §8).
53    pub condense: CondenseConfig,
54}
55
56impl Default for SessionConfig {
57    fn default() -> Self {
58        Self {
59            recap: RecapConfig::default(),
60            provider_persistence: true,
61            persist_provider_overrides: true,
62            enabled: true,
63            data_dir: ".zeph/sessions".to_owned(),
64            encrypt: false,
65            max_event_log_mb: 256,
66            condense: CondenseConfig::default(),
67        }
68    }
69}
70
71/// `[session.condense]` — durable context condensation policy (spec-068 §8).
72///
73/// Distinct from live in-memory compaction (`zeph-context`): condensation operates at the
74/// event-log level and is recorded as a replayable `Condensation` event.
75#[derive(Debug, Clone, Deserialize, Serialize)]
76#[serde(default)]
77pub struct CondenseConfig {
78    /// Provider name from `[[llm.providers]]` for condensation LLM calls.
79    ///
80    /// An empty [`ProviderName`] falls back to the primary provider. Default: `""`.
81    pub condense_provider: ProviderName,
82    /// Fraction of the context budget that triggers condensation on resume/mid-session.
83    /// Default: `0.85`.
84    pub threshold: f64,
85    /// Minimum number of recent events to preserve after condensation. Default: `20`.
86    pub keep_recent: usize,
87}
88
89impl Default for CondenseConfig {
90    fn default() -> Self {
91        Self {
92            condense_provider: ProviderName::default(),
93            threshold: 0.85,
94            keep_recent: 20,
95        }
96    }
97}
98
99/// `[session.recap]` — controls the session recap feature (#3064).
100///
101/// A recap summarises the previous conversation in a few sentences and is
102/// shown to the user when they resume a session that has a persisted digest.
103///
104/// # Example
105///
106/// ```toml
107/// [session.recap]
108/// on_resume = true
109/// max_tokens = 200
110/// provider = ""
111/// max_input_messages = 20
112/// ```
113#[derive(Debug, Clone, Deserialize, Serialize)]
114#[serde(default)]
115pub struct RecapConfig {
116    /// Show a recap of the previous session when resuming a conversation.
117    ///
118    /// When `true` and a persisted digest exists for the conversation, the
119    /// agent emits a brief recap before accepting the first user message.
120    /// Default: `true`.
121    pub on_resume: bool,
122
123    /// Maximum tokens for the recap text.
124    ///
125    /// Limits the length of the generated or cached recap. Default: `200`.
126    pub max_tokens: usize,
127
128    /// Provider name from `[[llm.providers]]` for recap LLM calls.
129    ///
130    /// An empty [`ProviderName`] falls back to the primary provider. Default: `""`.
131    pub provider: ProviderName,
132
133    /// Maximum recent messages included when generating a fresh recap.
134    ///
135    /// Used only when no cached digest is available (fresh-generation path).
136    /// Default: `20`.
137    pub max_input_messages: usize,
138}
139
140impl Default for RecapConfig {
141    fn default() -> Self {
142        Self {
143            on_resume: true,
144            max_tokens: 200,
145            provider: ProviderName::default(),
146            max_input_messages: 20,
147        }
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn condense_config_default_provider_is_empty() {
157        let cfg = CondenseConfig::default();
158        assert!(
159            cfg.condense_provider.is_empty(),
160            "condense_provider must default to empty (fallback to primary provider), matching \
161             the sibling recap/feedback/arise_trace provider fields — a non-empty default like \
162             \"fast\" spams a fallback WARN when no provider named \"fast\" is configured (#5665)"
163        );
164    }
165
166    #[test]
167    fn condense_config_empty_section_uses_defaults() {
168        let cfg: CondenseConfig = toml::from_str("").unwrap();
169        assert!(cfg.condense_provider.is_empty());
170        assert!((cfg.threshold - 0.85).abs() < f64::EPSILON);
171        assert_eq!(cfg.keep_recent, 20);
172    }
173
174    #[test]
175    fn condense_config_explicit_provider_roundtrip() {
176        let cfg: CondenseConfig = toml::from_str(r#"condense_provider = "fast""#).unwrap();
177        assert_eq!(cfg.condense_provider, "fast");
178    }
179}