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>/sessions/<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}