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    /// Resume-visibility settings: banner and `/history` bounds (spec-068 §13, §18).
55    pub resume: ResumeConfig,
56}
57
58impl Default for SessionConfig {
59    fn default() -> Self {
60        Self {
61            recap: RecapConfig::default(),
62            provider_persistence: true,
63            persist_provider_overrides: true,
64            enabled: true,
65            data_dir: ".zeph/sessions".to_owned(),
66            encrypt: false,
67            max_event_log_mb: 256,
68            condense: CondenseConfig::default(),
69            resume: ResumeConfig::default(),
70        }
71    }
72}
73
74/// `[session.resume]` — resume-visibility settings (spec-068 §13, §18).
75///
76/// Controls the neutral "Resuming session" banner shown by display-owning channels
77/// (CLI, TUI) on startup and the bound applied to the `/history` command with no
78/// argument. Has no effect on chat channels (Telegram/Discord/Slack) or ACP/IDE
79/// sessions, which are exempt from the automatic banner in v1 (spec-068 §13.2, §13.8).
80#[derive(Debug, Clone, Deserialize, Serialize)]
81#[serde(default)]
82#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
83pub struct ResumeConfig {
84    /// Show the neutral resume banner on display-owning channels (CLI/TUI) when
85    /// resuming a non-empty prior conversation. Default: `true`.
86    pub show_banner: bool,
87    /// Intended to always render full history on startup instead of the collapsed banner.
88    ///
89    /// Not consumed in v1 — no code path currently dispatches an `/history`-equivalent
90    /// expansion when this is set; enabling it changes nothing observable. Reserved for a
91    /// future PR (would need the same `/history all` pagination path this PR already built,
92    /// triggered automatically at startup instead of on user request). Default: `false`.
93    pub auto_expand: bool,
94    /// Bound applied to `/history` with no argument — the last N messages, sliced
95    /// before formatting (INV-SP-6). Default: `20`.
96    pub expand_default_lines: usize,
97    /// Opt-in: fold the cached `session.recap` summary into the resume banner.
98    ///
99    /// Not consumed in v1 — wiring this would require invoking the `/recap` LLM
100    /// path at startup, which is explicitly out of scope for spec-068 §13 (resume
101    /// visibility is a presentation-only feature over already-persisted data).
102    /// Reserved for a future PR. Default: `false`.
103    pub show_recap: bool,
104}
105
106impl Default for ResumeConfig {
107    fn default() -> Self {
108        Self {
109            show_banner: true,
110            auto_expand: false,
111            expand_default_lines: 20,
112            show_recap: false,
113        }
114    }
115}
116
117/// `[session.condense]` — durable context condensation policy (spec-068 §8).
118///
119/// Distinct from live in-memory compaction (`zeph-context`): condensation operates at the
120/// event-log level and is recorded as a replayable `Condensation` event.
121#[derive(Debug, Clone, Deserialize, Serialize)]
122#[serde(default)]
123pub struct CondenseConfig {
124    /// Provider name from `[[llm.providers]]` for condensation LLM calls.
125    ///
126    /// An empty [`ProviderName`] falls back to the primary provider. Default: `""`.
127    pub condense_provider: ProviderName,
128    /// Fraction of the context budget that triggers condensation on resume/mid-session.
129    /// Default: `0.85`.
130    pub threshold: f64,
131    /// Minimum number of recent events to preserve after condensation. Default: `20`.
132    pub keep_recent: usize,
133}
134
135impl Default for CondenseConfig {
136    fn default() -> Self {
137        Self {
138            condense_provider: ProviderName::default(),
139            threshold: 0.85,
140            keep_recent: 20,
141        }
142    }
143}
144
145/// `[session.recap]` — controls the session recap feature (#3064).
146///
147/// A recap summarises the previous conversation in a few sentences and is
148/// shown to the user when they resume a session that has a persisted digest.
149///
150/// # Example
151///
152/// ```toml
153/// [session.recap]
154/// on_resume = true
155/// max_tokens = 200
156/// provider = ""
157/// max_input_messages = 20
158/// ```
159#[derive(Debug, Clone, Deserialize, Serialize)]
160#[serde(default)]
161pub struct RecapConfig {
162    /// Show a recap of the previous session when resuming a conversation.
163    ///
164    /// When `true` and a persisted digest exists for the conversation, the
165    /// agent emits a brief recap before accepting the first user message.
166    /// Default: `true`.
167    pub on_resume: bool,
168
169    /// Maximum tokens for the recap text.
170    ///
171    /// Limits the length of the generated or cached recap. Default: `200`.
172    pub max_tokens: usize,
173
174    /// Provider name from `[[llm.providers]]` for recap LLM calls.
175    ///
176    /// An empty [`ProviderName`] falls back to the primary provider. Default: `""`.
177    pub provider: ProviderName,
178
179    /// Maximum recent messages included when generating a fresh recap.
180    ///
181    /// Used only when no cached digest is available (fresh-generation path).
182    /// Default: `20`.
183    pub max_input_messages: usize,
184}
185
186impl Default for RecapConfig {
187    fn default() -> Self {
188        Self {
189            on_resume: true,
190            max_tokens: 200,
191            provider: ProviderName::default(),
192            max_input_messages: 20,
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn condense_config_default_provider_is_empty() {
203        let cfg = CondenseConfig::default();
204        assert!(
205            cfg.condense_provider.is_empty(),
206            "condense_provider must default to empty (fallback to primary provider), matching \
207             the sibling recap/feedback/arise_trace provider fields — a non-empty default like \
208             \"fast\" spams a fallback WARN when no provider named \"fast\" is configured (#5665)"
209        );
210    }
211
212    #[test]
213    fn condense_config_empty_section_uses_defaults() {
214        let cfg: CondenseConfig = toml::from_str("").unwrap();
215        assert!(cfg.condense_provider.is_empty());
216        assert!((cfg.threshold - 0.85).abs() < f64::EPSILON);
217        assert_eq!(cfg.keep_recent, 20);
218    }
219
220    #[test]
221    fn condense_config_explicit_provider_roundtrip() {
222        let cfg: CondenseConfig = toml::from_str(r#"condense_provider = "fast""#).unwrap();
223        assert_eq!(cfg.condense_provider, "fast");
224    }
225
226    #[test]
227    fn resume_config_defaults() {
228        let cfg = ResumeConfig::default();
229        assert!(cfg.show_banner);
230        assert!(!cfg.auto_expand);
231        assert_eq!(cfg.expand_default_lines, 20);
232        assert!(!cfg.show_recap);
233    }
234
235    #[test]
236    fn resume_config_empty_section_uses_defaults() {
237        let cfg: ResumeConfig = toml::from_str("").unwrap();
238        assert!(cfg.show_banner);
239        assert_eq!(cfg.expand_default_lines, 20);
240    }
241
242    #[test]
243    fn resume_config_explicit_overrides_roundtrip() {
244        let cfg: ResumeConfig = toml::from_str(
245            "show_banner = false\nauto_expand = true\nexpand_default_lines = 50\nshow_recap = true",
246        )
247        .unwrap();
248        assert!(!cfg.show_banner);
249        assert!(cfg.auto_expand);
250        assert_eq!(cfg.expand_default_lines, 50);
251        assert!(cfg.show_recap);
252    }
253}