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}