Skip to main content

lucy/
session.rs

1use std::collections::HashSet;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, BufRead, BufReader, Write};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::de::{self, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::config::{
13    ensure_not_symlink, ensure_private_dir, ensure_private_file, lucy_dir, LlmSettings,
14};
15use crate::context::SkillEntry;
16use crate::model::{ChatMessage, ChatToolCall};
17use crate::redaction::{conflicts_with_protected_literal, redact_secret};
18
19#[cfg(unix)]
20use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
21
22static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
23
24#[derive(Debug)]
25pub struct SessionError(String);
26
27impl SessionError {
28    fn new(message: impl Into<String>) -> Self {
29        Self(message.into())
30    }
31}
32
33impl std::fmt::Display for SessionError {
34    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        formatter.write_str(&self.0)
36    }
37}
38
39impl std::error::Error for SessionError {}
40
41impl From<io::Error> for SessionError {
42    fn from(_error: io::Error) -> Self {
43        Self::new("session storage error")
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "record")]
49enum SessionRecord {
50    #[serde(rename = "session")]
51    Session {
52        version: u8,
53        session_id: String,
54        created_at: u64,
55        cwd: String,
56        boot_system_prompt: String,
57        llm: LlmSettings,
58        #[serde(default)]
59        skills: Vec<SkillEntry>,
60    },
61    #[serde(rename = "provider_settings")]
62    ProviderSettings {
63        timestamp: u64,
64        model: String,
65        effort: Option<String>,
66    },
67    #[serde(rename = "message")]
68    Message {
69        timestamp: u64,
70        message: ChatMessage,
71    },
72    #[serde(rename = "interruption")]
73    Interruption {
74        timestamp: u64,
75        reason: String,
76        phase: String,
77        #[serde(default)]
78        assistant_text: String,
79        #[serde(default)]
80        tool_calls: Vec<ChatToolCall>,
81        #[serde(default)]
82        tool_results: Vec<SessionToolResult>,
83    },
84    #[serde(rename = "compaction")]
85    Compaction {
86        timestamp: u64,
87        summary: String,
88        first_kept_message: usize,
89        tokens_before: usize,
90    },
91}
92
93/// A bounded, secret-safe observation retained for a canceled tool call.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct SessionToolResult {
96    pub id: String,
97    pub name: String,
98    pub result: Value,
99}
100
101/// The safe observations written when a user stops an active turn.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct InterruptionRecord {
104    #[serde(default)]
105    pub timestamp: u64,
106    pub reason: String,
107    pub phase: String,
108    #[serde(default)]
109    pub assistant_text: String,
110    #[serde(default)]
111    pub tool_calls: Vec<ChatToolCall>,
112    #[serde(default)]
113    pub tool_results: Vec<SessionToolResult>,
114}
115
116/// A durable summary boundary that lets provider context shrink without
117/// rewriting the append-only session history.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119pub struct CompactionRecord {
120    pub timestamp: u64,
121    pub summary: String,
122    /// Message ordinal (excluding the system prompt) at which retained context
123    /// begins. Messages before this boundary remain available for replay only.
124    pub first_kept_message: usize,
125    pub tokens_before: usize,
126}
127
128/// The ordered, replayable records after a session header.
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[serde(tag = "record")]
131pub enum SessionHistoryRecord {
132    #[serde(rename = "provider_settings")]
133    ProviderSettings {
134        timestamp: u64,
135        model: String,
136        effort: Option<String>,
137    },
138    #[serde(rename = "message")]
139    Message {
140        timestamp: u64,
141        message: ChatMessage,
142    },
143    #[serde(rename = "interruption")]
144    Interruption {
145        timestamp: u64,
146        reason: String,
147        phase: String,
148        assistant_text: String,
149        tool_calls: Vec<ChatToolCall>,
150        tool_results: Vec<SessionToolResult>,
151    },
152    #[serde(rename = "compaction")]
153    Compaction(CompactionRecord),
154}
155
156#[derive(Debug, Clone)]
157pub struct Session {
158    pub id: String,
159    pub path: PathBuf,
160    pub cwd: PathBuf,
161    pub boot_system_prompt: String,
162    pub llm: LlmSettings,
163    /// Skills are immutable per-session just like the boot prompt, so a
164    /// resumed `/<name>` skill command cannot silently load changed files.
165    pub skills: Vec<SkillEntry>,
166    pub created_at: u64,
167    pub updated_at: u64,
168    pub messages: Vec<ChatMessage>,
169    pub history: Vec<SessionHistoryRecord>,
170    secret: Option<String>,
171}
172
173#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
174pub struct SessionMetadata {
175    #[serde(rename = "type")]
176    pub record_type: &'static str,
177    pub session_id: String,
178    pub created_at: u64,
179    pub updated_at: u64,
180    pub first_message: Option<String>,
181    pub last_message: Option<String>,
182}
183
184impl Session {
185    pub fn create(
186        home: &Path,
187        cwd: &Path,
188        boot_system_prompt: String,
189        llm: LlmSettings,
190    ) -> Result<Self, SessionError> {
191        let secret = std::env::var(&llm.api_key_env).ok();
192        Self::create_with_secret(home, cwd, boot_system_prompt, llm, secret.as_deref())
193    }
194
195    pub fn create_with_secret(
196        home: &Path,
197        cwd: &Path,
198        boot_system_prompt: String,
199        llm: LlmSettings,
200        secret: Option<&str>,
201    ) -> Result<Self, SessionError> {
202        Self::create_with_skills_and_secret(home, cwd, boot_system_prompt, llm, Vec::new(), secret)
203    }
204
205    pub fn create_with_skills_and_secret(
206        home: &Path,
207        cwd: &Path,
208        boot_system_prompt: String,
209        llm: LlmSettings,
210        skills: Vec<SkillEntry>,
211        secret: Option<&str>,
212    ) -> Result<Self, SessionError> {
213        let cwd = fs::canonicalize(cwd)
214            .map_err(|_error| SessionError::new("unable to resolve session cwd"))?;
215        let sessions_directory = sessions_dir(home);
216        ensure_private_dir(&lucy_dir(home))?;
217        ensure_private_dir(&sessions_directory)?;
218        let created_at = now();
219
220        if let Some(secret) = secret {
221            if conflicts_with_protected_literal(secret) {
222                return Err(session_header_rejected(secret));
223            }
224        }
225
226        for _ in 0..16 {
227            let id = new_session_id();
228            let path = sessions_directory.join(format!("{id}.jsonl"));
229            let record = SessionRecord::Session {
230                version: 1,
231                session_id: id.clone(),
232                created_at,
233                cwd: cwd.display().to_string(),
234                boot_system_prompt: boot_system_prompt.clone(),
235                llm: llm.clone(),
236                skills: skills.clone(),
237            };
238            if let Some(secret) = secret {
239                if record_contains_secret(&record, secret) {
240                    return Err(session_header_rejected(secret));
241                }
242            }
243
244            let mut options = OpenOptions::new();
245            options.write(true).create_new(true);
246            #[cfg(unix)]
247            {
248                use std::os::unix::fs::OpenOptionsExt;
249                options.mode(0o600);
250            }
251            match options.open(&path) {
252                Ok(mut file) => {
253                    ensure_private_file(&path)?;
254                    write_record(&mut file, &record)?;
255                    return Ok(Self {
256                        id,
257                        path,
258                        cwd,
259                        boot_system_prompt,
260                        llm,
261                        skills,
262                        created_at,
263                        updated_at: created_at,
264                        messages: Vec::new(),
265                        history: Vec::new(),
266                        secret: secret.map(str::to_owned),
267                    });
268                }
269                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
270                Err(_error) => return Err(SessionError::new("unable to create session file")),
271            }
272        }
273        Err(SessionError::new("unable to allocate a unique session id"))
274    }
275
276    pub fn resume(home: &Path, id: &str) -> Result<Self, SessionError> {
277        validate_session_id(id)?;
278        let directory = sessions_dir(home);
279        let lucy_directory = lucy_dir(home);
280        ensure_not_symlink(&lucy_directory)?;
281        if lucy_directory.is_dir() {
282            ensure_private_dir(&lucy_directory)?;
283        }
284        ensure_not_symlink(&directory)?;
285        if directory.is_dir() {
286            ensure_private_dir(&directory)?;
287        }
288        let path = directory.join(format!("{id}.jsonl"));
289        ensure_not_symlink(&path)?;
290        if !path.is_file() {
291            return Err(SessionError::new("session not found"));
292        }
293
294        ensure_private_file(&path)?;
295        let raw =
296            fs::read(&path).map_err(|_error| SessionError::new("unable to read session file"))?;
297        let active_secret = session_header_secret(&raw);
298        if let Some(secret) = active_secret.as_deref() {
299            if conflicts_with_protected_literal(secret) || bytes_contain_secret(&raw, secret) {
300                return Err(session_header_rejected(secret));
301            }
302        }
303
304        let reader = BufReader::new(raw.as_slice());
305        let mut header = None;
306        let mut messages = Vec::new();
307        let mut history = Vec::new();
308        let mut updated_at = None;
309
310        for (line_number, line) in reader.lines().enumerate() {
311            let line = line.map_err(|_error| {
312                session_error("unable to read session file", active_secret.as_deref())
313            })?;
314            if line.trim().is_empty() {
315                continue;
316            }
317            let value = parse_json_value(&line).map_err(|_error| {
318                session_error(
319                    format!("invalid session record at line {}", line_number + 1),
320                    active_secret.as_deref(),
321                )
322            })?;
323            if let Some(secret) = active_secret.as_deref() {
324                if json_value_contains_secret(&value, secret) {
325                    return Err(session_header_rejected(secret));
326                }
327            }
328            let record: SessionRecord = serde_json::from_value(value).map_err(|_error| {
329                session_error(
330                    format!("invalid session record at line {}", line_number + 1),
331                    active_secret.as_deref(),
332                )
333            })?;
334            if let Some(secret) = active_secret.as_deref() {
335                if record_contains_secret(&record, secret) {
336                    return Err(session_header_rejected(secret));
337                }
338            }
339            match record {
340                SessionRecord::Session {
341                    version,
342                    session_id,
343                    created_at,
344                    cwd,
345                    boot_system_prompt,
346                    llm,
347                    skills,
348                } => {
349                    if version != 1 || session_id != id || header.is_some() {
350                        return Err(session_error(
351                            "invalid session header",
352                            active_secret.as_deref(),
353                        ));
354                    }
355                    header = Some((created_at, cwd, boot_system_prompt, llm, skills));
356                }
357                SessionRecord::ProviderSettings {
358                    timestamp,
359                    model,
360                    effort,
361                } => {
362                    if header.is_none() {
363                        return Err(session_error(
364                            "session provider settings precede header",
365                            active_secret.as_deref(),
366                        ));
367                    }
368                    updated_at = Some(timestamp);
369                    history.push(SessionHistoryRecord::ProviderSettings {
370                        timestamp,
371                        model,
372                        effort,
373                    });
374                }
375                SessionRecord::Message { timestamp, message } => {
376                    if header.is_none() {
377                        return Err(session_error(
378                            "session message precedes header",
379                            active_secret.as_deref(),
380                        ));
381                    }
382                    updated_at = Some(timestamp);
383                    history.push(SessionHistoryRecord::Message {
384                        timestamp,
385                        message: message.clone(),
386                    });
387                    messages.push(message);
388                }
389                SessionRecord::Interruption {
390                    timestamp,
391                    reason,
392                    phase,
393                    assistant_text,
394                    tool_calls,
395                    tool_results,
396                } => {
397                    if header.is_none() {
398                        return Err(session_error(
399                            "session interruption precedes header",
400                            active_secret.as_deref(),
401                        ));
402                    }
403                    updated_at = Some(timestamp);
404                    history.push(SessionHistoryRecord::Interruption {
405                        timestamp,
406                        reason,
407                        phase,
408                        assistant_text,
409                        tool_calls,
410                        tool_results,
411                    });
412                }
413                SessionRecord::Compaction {
414                    timestamp,
415                    summary,
416                    first_kept_message,
417                    tokens_before,
418                } => {
419                    if header.is_none() {
420                        return Err(session_error(
421                            "session compaction precedes header",
422                            active_secret.as_deref(),
423                        ));
424                    }
425                    updated_at = Some(timestamp);
426                    history.push(SessionHistoryRecord::Compaction(CompactionRecord {
427                        timestamp,
428                        summary,
429                        first_kept_message,
430                        tokens_before,
431                    }));
432                }
433            }
434        }
435
436        let message_count = messages.len();
437        if history.iter().any(|record| {
438            matches!(
439                record,
440                SessionHistoryRecord::Compaction(compaction)
441                    if compaction.first_kept_message > message_count
442            )
443        }) {
444            return Err(session_error(
445                "invalid compaction boundary",
446                active_secret.as_deref(),
447            ));
448        }
449
450        let Some((created_at, cwd, boot_system_prompt, llm, skills)) = header else {
451            return Err(session_error(
452                "session has no header",
453                active_secret.as_deref(),
454            ));
455        };
456        let cwd = PathBuf::from(cwd);
457        Ok(Self {
458            id: id.to_owned(),
459            path,
460            cwd,
461            boot_system_prompt,
462            llm,
463            skills,
464            created_at,
465            updated_at: updated_at.unwrap_or(created_at),
466            messages,
467            history,
468            secret: active_secret,
469        })
470    }
471
472    pub fn append_provider_settings(
473        &mut self,
474        model: String,
475        effort: Option<String>,
476    ) -> Result<(), SessionError> {
477        let timestamp = now();
478        let record = SessionRecord::ProviderSettings {
479            timestamp,
480            model: model.clone(),
481            effort: effort.clone(),
482        };
483        if let Some(secret) = self.secret.as_deref() {
484            if record_contains_secret(&record, secret) {
485                return Err(session_record_rejected(secret));
486            }
487        }
488        let mut file = open_session_for_append(&self.path)?;
489        write_record(&mut file, &record)?;
490        self.history.push(SessionHistoryRecord::ProviderSettings {
491            timestamp,
492            model,
493            effort,
494        });
495        self.updated_at = timestamp;
496        Ok(())
497    }
498
499    pub fn append_message(&mut self, message: ChatMessage) -> Result<(), SessionError> {
500        let timestamp = now();
501        let record = SessionRecord::Message {
502            timestamp,
503            message: message.clone(),
504        };
505        if let Some(secret) = self.secret.as_deref() {
506            if record_contains_secret(&record, secret) {
507                return Err(session_record_rejected(secret));
508            }
509        }
510        let mut file = open_session_for_append(&self.path)?;
511        write_record(&mut file, &record)?;
512        self.messages.push(message.clone());
513        self.history
514            .push(SessionHistoryRecord::Message { timestamp, message });
515        self.updated_at = timestamp;
516        Ok(())
517    }
518
519    pub fn append_interruption(
520        &mut self,
521        mut interruption: InterruptionRecord,
522    ) -> Result<(), SessionError> {
523        let timestamp = now();
524        interruption.timestamp = timestamp;
525        let record = SessionRecord::Interruption {
526            timestamp,
527            reason: interruption.reason.clone(),
528            phase: interruption.phase.clone(),
529            assistant_text: interruption.assistant_text.clone(),
530            tool_calls: interruption.tool_calls.clone(),
531            tool_results: interruption.tool_results.clone(),
532        };
533        if let Some(secret) = self.secret.as_deref() {
534            if record_contains_secret(&record, secret) {
535                return Err(session_record_rejected(secret));
536            }
537        }
538        let mut file = open_session_for_append(&self.path)?;
539        write_record(&mut file, &record)?;
540        self.history.push(SessionHistoryRecord::Interruption {
541            timestamp,
542            reason: interruption.reason,
543            phase: interruption.phase,
544            assistant_text: interruption.assistant_text,
545            tool_calls: interruption.tool_calls,
546            tool_results: interruption.tool_results,
547        });
548        self.updated_at = timestamp;
549        Ok(())
550    }
551
552    /// Append a summary boundary without deleting the historical records that
553    /// preceded it. `first_kept_message` counts ordinary message records from
554    /// the start of the session, excluding the boot system prompt.
555    pub fn append_compaction(
556        &mut self,
557        summary: String,
558        first_kept_message: usize,
559        tokens_before: usize,
560    ) -> Result<(), SessionError> {
561        let timestamp = now();
562        let record = SessionRecord::Compaction {
563            timestamp,
564            summary: summary.clone(),
565            first_kept_message,
566            tokens_before,
567        };
568        if let Some(secret) = self.secret.as_deref() {
569            if record_contains_secret(&record, secret) {
570                return Err(session_record_rejected(secret));
571            }
572        }
573        let mut file = open_session_for_append(&self.path)?;
574        write_record(&mut file, &record)?;
575        self.history
576            .push(SessionHistoryRecord::Compaction(CompactionRecord {
577                timestamp,
578                summary,
579                first_kept_message,
580                tokens_before,
581            }));
582        self.updated_at = timestamp;
583        Ok(())
584    }
585
586    pub fn provider_messages(&self) -> Vec<ChatMessage> {
587        let latest_compaction = self.history.iter().rev().find_map(|record| match record {
588            SessionHistoryRecord::Compaction(compaction) => Some(compaction),
589            _ => None,
590        });
591        let first_kept_message = latest_compaction.map(|compaction| compaction.first_kept_message);
592        let interruption_results = self
593            .history
594            .iter()
595            .filter_map(|record| match record {
596                SessionHistoryRecord::Interruption {
597                    phase,
598                    tool_results,
599                    ..
600                } if phase == "cmd" => Some(tool_results),
601                _ => None,
602            })
603            .flatten()
604            .count();
605        let mut messages = Vec::with_capacity(
606            self.messages.len()
607                + 1
608                + interruption_results
609                + usize::from(latest_compaction.is_some()),
610        );
611        let mut declared_tool_calls = HashSet::new();
612        let mut completed_tool_calls = HashSet::new();
613        messages.push(ChatMessage::system(self.boot_system_prompt.clone()));
614        if let Some(compaction) = latest_compaction {
615            messages.push(compaction_summary_message(&compaction.summary));
616        }
617
618        let mut message_ordinal = 0usize;
619        for record in &self.history {
620            match record {
621                SessionHistoryRecord::Message { message, .. } => {
622                    let include =
623                        first_kept_message.is_none_or(|boundary| message_ordinal >= boundary);
624                    message_ordinal += 1;
625                    if !include {
626                        continue;
627                    }
628                    if message.role == "assistant" {
629                        declared_tool_calls
630                            .extend(message.tool_calls.iter().map(|call| call.id.clone()));
631                    }
632                    if message.role == "tool" {
633                        if let Some(id) = message.tool_call_id.as_deref() {
634                            completed_tool_calls.insert(id.to_owned());
635                        }
636                    }
637                    messages.push(message.clone());
638                }
639                SessionHistoryRecord::Interruption {
640                    phase,
641                    tool_results,
642                    ..
643                } if phase == "cmd" => {
644                    for observation in tool_results {
645                        if !declared_tool_calls.contains(&observation.id)
646                            || completed_tool_calls.contains(&observation.id)
647                        {
648                            continue;
649                        }
650                        let Ok(content) = serde_json::to_string(&observation.result) else {
651                            continue;
652                        };
653                        messages.push(ChatMessage::tool(
654                            observation.id.clone(),
655                            observation.name.clone(),
656                            content,
657                        ));
658                        completed_tool_calls.insert(observation.id.clone());
659                    }
660                }
661                SessionHistoryRecord::ProviderSettings { .. }
662                | SessionHistoryRecord::Interruption { .. }
663                | SessionHistoryRecord::Compaction(_) => {}
664            }
665        }
666        messages
667    }
668
669    pub fn list(home: &Path) -> Result<Vec<SessionMetadata>, SessionError> {
670        let directory = sessions_dir(home);
671        let lucy_directory = lucy_dir(home);
672        ensure_not_symlink(&lucy_directory)?;
673        if lucy_directory.is_dir() {
674            ensure_private_dir(&lucy_directory)?;
675        }
676        ensure_not_symlink(&directory)?;
677        if directory.is_dir() {
678            ensure_private_dir(&directory)?;
679        }
680        let entries = match fs::read_dir(&directory) {
681            Ok(entries) => entries,
682            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
683            Err(_error) => return Err(SessionError::new("unable to list sessions")),
684        };
685
686        let mut paths = Vec::new();
687        for entry in entries {
688            let entry = entry?;
689            let path = entry.path();
690            let metadata = match fs::symlink_metadata(&path) {
691                Ok(metadata) => metadata,
692                Err(_) => continue,
693            };
694            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
695                && metadata.is_file()
696            {
697                paths.push(path);
698            }
699        }
700        paths.sort();
701
702        let mut metadata = Vec::new();
703        for path in paths {
704            let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
705                continue;
706            };
707            let Ok(session) = Self::resume(home, id) else {
708                continue;
709            };
710            metadata.push(SessionMetadata {
711                record_type: "session_metadata",
712                session_id: session.id,
713                created_at: session.created_at,
714                updated_at: session.updated_at,
715                first_message: session.messages.first().map(safe_message_summary),
716                last_message: session.messages.last().map(safe_message_summary),
717            });
718        }
719        Ok(metadata)
720    }
721}
722
723struct DuplicateKeyValue(Value);
724
725impl<'de> Deserialize<'de> for DuplicateKeyValue {
726    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
727    where
728        D: Deserializer<'de>,
729    {
730        deserializer
731            .deserialize_any(DuplicateKeyValueVisitor)
732            .map(Self)
733    }
734}
735
736struct DuplicateKeyValueSeed;
737
738impl<'de> DeserializeSeed<'de> for DuplicateKeyValueSeed {
739    type Value = Value;
740
741    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
742    where
743        D: Deserializer<'de>,
744    {
745        deserializer.deserialize_any(DuplicateKeyValueVisitor)
746    }
747}
748
749struct DuplicateKeyValueVisitor;
750
751impl<'de> Visitor<'de> for DuplicateKeyValueVisitor {
752    type Value = Value;
753
754    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
755        formatter.write_str("a valid JSON value")
756    }
757
758    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
759        Ok(Value::Bool(value))
760    }
761
762    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
763        Ok(Value::Number(value.into()))
764    }
765
766    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
767    where
768        E: de::Error,
769    {
770        serde_json::Number::from_i128(value)
771            .map(Value::Number)
772            .ok_or_else(|| de::Error::custom("JSON number out of range"))
773    }
774
775    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
776        Ok(Value::Number(value.into()))
777    }
778
779    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
780    where
781        E: de::Error,
782    {
783        serde_json::Number::from_u128(value)
784            .map(Value::Number)
785            .ok_or_else(|| de::Error::custom("JSON number out of range"))
786    }
787
788    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
789    where
790        E: de::Error,
791    {
792        Ok(serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number))
793    }
794
795    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
796        Ok(Value::String(value.to_owned()))
797    }
798
799    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
800        Ok(Value::String(value))
801    }
802
803    fn visit_none<E>(self) -> Result<Self::Value, E> {
804        Ok(Value::Null)
805    }
806
807    fn visit_unit<E>(self) -> Result<Self::Value, E> {
808        Ok(Value::Null)
809    }
810
811    fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
812    where
813        A: SeqAccess<'de>,
814    {
815        let mut values = Vec::new();
816        while let Some(value) = access.next_element_seed(DuplicateKeyValueSeed)? {
817            values.push(value);
818        }
819        Ok(Value::Array(values))
820    }
821
822    fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
823    where
824        A: MapAccess<'de>,
825    {
826        let mut values = serde_json::Map::new();
827        while let Some(key) = access.next_key::<String>()? {
828            if values.contains_key(&key) {
829                return Err(de::Error::custom("duplicate object key"));
830            }
831            let value = access.next_value_seed(DuplicateKeyValueSeed)?;
832            values.insert(key, value);
833        }
834        Ok(Value::Object(values))
835    }
836}
837
838fn parse_json_value(line: &str) -> Result<Value, serde_json::Error> {
839    serde_json::from_str::<DuplicateKeyValue>(line).map(|value| value.0)
840}
841
842fn session_header_secret(raw: &[u8]) -> Option<String> {
843    let line = raw
844        .split(|byte| *byte == b'\n')
845        .find(|line| !line.iter().all(|byte| byte.is_ascii_whitespace()))?;
846    let value = parse_json_value(std::str::from_utf8(line).ok()?).ok()?;
847    let api_key_env = value
848        .get("llm")
849        .and_then(|llm| llm.get("api_key_env"))
850        .and_then(Value::as_str)?;
851    let secret = std::env::var(api_key_env).ok()?;
852    (!secret.is_empty()).then_some(secret)
853}
854
855fn bytes_contain_secret(raw: &[u8], secret: &str) -> bool {
856    let secret = secret.as_bytes();
857    !secret.is_empty() && raw.windows(secret.len()).any(|window| window == secret)
858}
859
860fn record_contains_secret(record: &SessionRecord, secret: &str) -> bool {
861    if secret.is_empty() {
862        return false;
863    }
864    if serde_json::to_vec(record)
865        .ok()
866        .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
867    {
868        return true;
869    }
870    match record {
871        SessionRecord::Session {
872            version,
873            session_id,
874            created_at,
875            cwd,
876            boot_system_prompt,
877            llm,
878            skills,
879        } => {
880            version.to_string().contains(secret)
881                || session_id.contains(secret)
882                || created_at.to_string().contains(secret)
883                || cwd.contains(secret)
884                || boot_system_prompt.contains(secret)
885                || llm.base_url.contains(secret)
886                || llm.model.contains(secret)
887                || llm.api_key_env.contains(secret)
888                || skills.iter().any(|skill| {
889                    skill.name.contains(secret)
890                        || skill.description.contains(secret)
891                        || skill.path.display().to_string().contains(secret)
892                        || skill.contents.contains(secret)
893                })
894        }
895        SessionRecord::ProviderSettings {
896            timestamp,
897            model,
898            effort,
899        } => {
900            timestamp.to_string().contains(secret)
901                || model.contains(secret)
902                || effort
903                    .as_deref()
904                    .is_some_and(|value| value.contains(secret))
905        }
906        SessionRecord::Message { timestamp, message } => {
907            timestamp.to_string().contains(secret) || message_contains_secret(message, secret)
908        }
909        SessionRecord::Interruption {
910            timestamp,
911            reason,
912            phase,
913            assistant_text,
914            tool_calls,
915            tool_results,
916        } => {
917            timestamp.to_string().contains(secret)
918                || reason.contains(secret)
919                || phase.contains(secret)
920                || assistant_text.contains(secret)
921                || tool_calls.iter().any(|call| {
922                    call.id.contains(secret)
923                        || call.name.contains(secret)
924                        || call.arguments.contains(secret)
925                })
926                || tool_results.iter().any(|observation| {
927                    observation.id.contains(secret)
928                        || observation.name.contains(secret)
929                        || json_value_contains_secret(&observation.result, secret)
930                })
931        }
932        SessionRecord::Compaction {
933            timestamp,
934            summary,
935            first_kept_message,
936            tokens_before,
937        } => {
938            timestamp.to_string().contains(secret)
939                || summary.contains(secret)
940                || first_kept_message.to_string().contains(secret)
941                || tokens_before.to_string().contains(secret)
942        }
943    }
944}
945
946fn message_contains_secret(message: &ChatMessage, secret: &str) -> bool {
947    message.role.contains(secret)
948        || message
949            .content
950            .as_deref()
951            .is_some_and(|content| content.contains(secret))
952        || message.reasoning_details.as_ref().is_some_and(|details| {
953            details
954                .iter()
955                .any(|detail| json_value_contains_secret(detail, secret))
956        })
957        || message
958            .name
959            .as_deref()
960            .is_some_and(|name| name.contains(secret))
961        || message
962            .tool_call_id
963            .as_deref()
964            .is_some_and(|id| id.contains(secret))
965        || message.tool_calls.iter().any(|call| {
966            call.id.contains(secret)
967                || call.name.contains(secret)
968                || call.arguments.contains(secret)
969                || tool_arguments_contain_secret(&call.arguments, secret)
970        })
971}
972
973fn tool_arguments_contain_secret(arguments: &str, secret: &str) -> bool {
974    serde_json::from_str::<Value>(arguments)
975        .ok()
976        .is_some_and(|value| json_value_contains_secret(&value, secret))
977}
978
979fn json_value_contains_secret(value: &Value, secret: &str) -> bool {
980    match value {
981        Value::String(text) => text.contains(secret),
982        Value::Array(values) => values
983            .iter()
984            .any(|value| json_value_contains_secret(value, secret)),
985        Value::Object(object) => {
986            object.keys().any(|key| key.contains(secret))
987                || object
988                    .values()
989                    .any(|value| json_value_contains_secret(value, secret))
990        }
991        Value::Number(number) => number.to_string().contains(secret),
992        Value::Bool(_) | Value::Null => false,
993    }
994}
995
996fn session_error(message: impl Into<String>, secret: Option<&str>) -> SessionError {
997    let message = message.into();
998    SessionError::new(redact_secret(&message, secret))
999}
1000
1001fn session_header_rejected(secret: &str) -> SessionError {
1002    session_error("session header rejected", Some(secret))
1003}
1004
1005fn session_record_rejected(secret: &str) -> SessionError {
1006    session_error("session record rejected", Some(secret))
1007}
1008
1009fn open_session_for_append(path: &Path) -> Result<File, SessionError> {
1010    let mut options = OpenOptions::new();
1011    options.write(true).append(true);
1012    #[cfg(unix)]
1013    options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
1014    #[cfg(not(unix))]
1015    ensure_not_symlink(path)?;
1016
1017    let file = options.open(path)?;
1018    let metadata = file.metadata()?;
1019    if !metadata.is_file() {
1020        return Err(SessionError::new(
1021            "session file is not a regular private file",
1022        ));
1023    }
1024    #[cfg(unix)]
1025    if metadata.permissions().mode() & 0o777 != 0o600 {
1026        return Err(SessionError::new(
1027            "session file is not a regular private file",
1028        ));
1029    }
1030    Ok(file)
1031}
1032
1033fn write_record(file: &mut File, record: &SessionRecord) -> Result<(), SessionError> {
1034    let line = serde_json::to_string(record)
1035        .map_err(|error| SessionError::new(format!("unable to encode session record: {error}")))?;
1036    file.write_all(line.as_bytes())?;
1037    file.write_all(b"\n")?;
1038    file.flush()?;
1039    Ok(())
1040}
1041
1042const COMPACTION_SUMMARY_PREFIX: &str = "<context_compaction>\nThe earlier conversation was compacted. Treat the following summary as authoritative context for the continued turn.\n\n";
1043const COMPACTION_SUMMARY_SUFFIX: &str = "\n</context_compaction>";
1044
1045fn compaction_summary_message(summary: &str) -> ChatMessage {
1046    ChatMessage::user(format!(
1047        "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
1048    ))
1049}
1050
1051fn safe_message_summary(message: &ChatMessage) -> String {
1052    let role = message.role.as_str();
1053    let text = message
1054        .content
1055        .as_deref()
1056        .or_else(|| message.tool_calls.first().map(|call| call.name.as_str()))
1057        .unwrap_or("");
1058    let mut summary = text.chars().take(120).collect::<String>();
1059    if text.chars().count() > 120 {
1060        summary.push('…');
1061    }
1062    format!("{role}: {summary}")
1063}
1064
1065pub fn sessions_dir(home: &Path) -> PathBuf {
1066    home.join(".lucy").join("sessions")
1067}
1068
1069pub fn validate_session_id(id: &str) -> Result<(), SessionError> {
1070    if id.is_empty()
1071        || !id.chars().all(|character| {
1072            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
1073        })
1074    {
1075        return Err(SessionError::new("session id contains invalid characters"));
1076    }
1077    Ok(())
1078}
1079
1080fn new_session_id() -> String {
1081    let timestamp = now();
1082    let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
1083    format!("{timestamp}-{}-{counter}", std::process::id())
1084}
1085
1086fn now() -> u64 {
1087    SystemTime::now()
1088        .duration_since(UNIX_EPOCH)
1089        .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64)
1090        .unwrap_or(0)
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use crate::config::LlmSettings;
1097    #[cfg(unix)]
1098    use std::ffi::CString;
1099    #[cfg(unix)]
1100    use std::os::unix::ffi::OsStrExt;
1101    #[cfg(unix)]
1102    use std::os::unix::fs::{symlink, PermissionsExt};
1103    use std::sync::atomic::{AtomicU64, Ordering};
1104    use std::time::{SystemTime, UNIX_EPOCH};
1105
1106    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1107
1108    fn temporary_home() -> PathBuf {
1109        loop {
1110            let stamp = SystemTime::now()
1111                .duration_since(UNIX_EPOCH)
1112                .expect("clock")
1113                .as_nanos();
1114            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1115            let path = std::env::temp_dir().join(format!(
1116                "lucy-session-{stamp}-{}-{counter}",
1117                std::process::id()
1118            ));
1119            match fs::create_dir(&path) {
1120                Ok(()) => return path,
1121                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1122                Err(error) => panic!("temp home: {error}"),
1123            }
1124        }
1125    }
1126
1127    #[cfg(unix)]
1128    #[test]
1129    fn append_rejects_a_non_private_opened_session_file_without_chmod() {
1130        let home = temporary_home();
1131        let cwd = std::env::current_dir().expect("cwd");
1132        let llm = LlmSettings {
1133            base_url: "http://localhost".to_owned(),
1134            model: "model".to_owned(),
1135            api_key_env: "LUCY_APPEND_TEST_KEY".to_owned(),
1136            effort: None,
1137        };
1138        let mut session =
1139            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1140        fs::set_permissions(&session.path, fs::Permissions::from_mode(0o644))
1141            .expect("make session group-readable");
1142
1143        let error = session
1144            .append_message(ChatMessage::user("must not append".to_owned()))
1145            .expect_err("unsafe permissions should be rejected");
1146        assert!(error.to_string().contains("private"));
1147        assert_eq!(
1148            fs::metadata(&session.path)
1149                .expect("session metadata")
1150                .permissions()
1151                .mode()
1152                & 0o777,
1153            0o644
1154        );
1155
1156        fs::remove_dir_all(home).expect("remove temp home");
1157    }
1158
1159    #[cfg(unix)]
1160    #[test]
1161    fn append_rejects_a_symlinked_session_path() {
1162        let home = temporary_home();
1163        let cwd = std::env::current_dir().expect("cwd");
1164        let llm = LlmSettings {
1165            base_url: "http://localhost".to_owned(),
1166            model: "model".to_owned(),
1167            api_key_env: "LUCY_APPEND_LINK_KEY".to_owned(),
1168            effort: None,
1169        };
1170        let mut session =
1171            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1172        let target = home.join("append-target.jsonl");
1173        fs::write(&target, "target\n").expect("target file");
1174        fs::remove_file(&session.path).expect("remove session path");
1175        symlink(&target, &session.path).expect("session symlink");
1176
1177        session
1178            .append_message(ChatMessage::user("must not append".to_owned()))
1179            .expect_err("symlink should be rejected");
1180        assert_eq!(
1181            fs::read_to_string(&target).expect("target contents"),
1182            "target\n"
1183        );
1184
1185        fs::remove_dir_all(home).expect("remove temp home");
1186    }
1187
1188    #[cfg(unix)]
1189    #[test]
1190    fn append_rejects_a_fifo_without_blocking() {
1191        let home = temporary_home();
1192        let cwd = std::env::current_dir().expect("cwd");
1193        let llm = LlmSettings {
1194            base_url: "http://localhost".to_owned(),
1195            model: "model".to_owned(),
1196            api_key_env: "LUCY_APPEND_FIFO_KEY".to_owned(),
1197            effort: None,
1198        };
1199        let mut session =
1200            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1201        fs::remove_file(&session.path).expect("remove session path");
1202        let fifo_path = CString::new(session.path.as_os_str().as_bytes()).expect("FIFO path");
1203        let result = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) };
1204        assert_eq!(result, 0, "mkfifo: {:?}", io::Error::last_os_error());
1205
1206        session
1207            .append_message(ChatMessage::user("must not append".to_owned()))
1208            .expect_err("FIFO should be rejected without a blocking open");
1209
1210        fs::remove_dir_all(home).expect("remove temp home");
1211    }
1212
1213    #[cfg(unix)]
1214    #[test]
1215    fn rejects_symlinked_session_files_and_directories() {
1216        let home = temporary_home();
1217        let directory = home.join(".lucy/sessions");
1218        fs::create_dir_all(&directory).expect("sessions directory");
1219        let target = home.join("session-target.jsonl");
1220        fs::write(&target, "not a session\n").expect("target session");
1221        let path = directory.join("linked.jsonl");
1222        symlink(&target, &path).expect("session symlink");
1223        assert!(Session::resume(&home, "linked").is_err());
1224        assert!(Session::list(&home).expect("list sessions").is_empty());
1225        fs::remove_file(path).expect("remove session symlink");
1226        fs::remove_file(target).expect("remove target session");
1227        fs::remove_dir_all(home).expect("remove temp home");
1228
1229        let home = temporary_home();
1230        let lucy = home.join(".lucy");
1231        fs::create_dir(&lucy).expect("Lucy directory");
1232        let target = home.join("sessions-target");
1233        fs::create_dir(&target).expect("target sessions directory");
1234        symlink(&target, lucy.join("sessions")).expect("sessions directory symlink");
1235        assert!(Session::list(&home).is_err());
1236        fs::remove_file(lucy.join("sessions")).expect("remove sessions directory symlink");
1237        fs::remove_dir(target).expect("remove target sessions directory");
1238        fs::remove_dir(lucy).expect("remove Lucy directory");
1239        fs::remove_dir(home).expect("remove temp home");
1240    }
1241
1242    #[test]
1243    fn resume_rejects_duplicate_header_as_an_invalid_record() {
1244        let home = temporary_home();
1245        let sessions = home.join(".lucy/sessions");
1246        fs::create_dir_all(&sessions).expect("sessions");
1247        let id = "duplicate-header";
1248        let environment = format!("LUCY_DUPLICATE_HEADER_{}", std::process::id());
1249        let secret = "provider-secret";
1250        std::env::set_var(&environment, secret);
1251        let header = format!(
1252            r#"{{"record":"session","version":1,"session_id":"{id}","created_at":1,"cwd":".","boot_system_prompt":"{secret}","boot_system_prompt":"safe","llm":{{"base_url":"http://localhost","model":"model","api_key_env":"{environment}"}}}}"#
1253        );
1254        fs::write(sessions.join(format!("{id}.jsonl")), format!("{header}\n"))
1255            .expect("duplicate header");
1256
1257        let error = Session::resume(&home, id).expect_err("duplicate header should be rejected");
1258        assert_eq!(error.to_string(), "invalid session record at line 1");
1259
1260        std::env::remove_var(environment);
1261        fs::remove_dir_all(home).expect("cleanup");
1262    }
1263
1264    #[test]
1265    fn creates_appends_resumes_and_lists_jsonl_session() {
1266        let home = temporary_home();
1267        let cwd = std::env::current_dir().expect("cwd");
1268        let llm = LlmSettings {
1269            base_url: "http://localhost:1234/api/v1".to_owned(),
1270            model: "test-model".to_owned(),
1271            api_key_env: "TEST_KEY".to_owned(),
1272            effort: None,
1273        };
1274        let mut session =
1275            Session::create(&home, &cwd, "stable prompt".to_owned(), llm.clone()).expect("create");
1276        #[cfg(unix)]
1277        {
1278            use std::os::unix::fs::PermissionsExt;
1279            assert_eq!(
1280                fs::metadata(sessions_dir(&home))
1281                    .expect("sessions directory metadata")
1282                    .permissions()
1283                    .mode()
1284                    & 0o777,
1285                0o700
1286            );
1287            assert_eq!(
1288                fs::metadata(&session.path)
1289                    .expect("session file metadata")
1290                    .permissions()
1291                    .mode()
1292                    & 0o777,
1293                0o600
1294            );
1295        }
1296        let id = session.id.clone();
1297        session
1298            .append_message(ChatMessage::user("first".to_owned()))
1299            .expect("append user");
1300        session
1301            .append_message(ChatMessage::assistant("last".to_owned(), Vec::new()))
1302            .expect("append assistant");
1303
1304        let resumed = Session::resume(&home, &id).expect("resume");
1305        assert_eq!(resumed.boot_system_prompt, "stable prompt");
1306        assert_eq!(resumed.llm, llm);
1307        assert_eq!(resumed.messages.len(), 2);
1308        assert_eq!(resumed.cwd, fs::canonicalize(cwd).expect("canonical cwd"));
1309        let listed = Session::list(&home).expect("list");
1310        assert_eq!(listed.len(), 1);
1311        assert_eq!(listed[0].session_id, id);
1312        assert!(listed[0]
1313            .first_message
1314            .as_deref()
1315            .is_some_and(|summary| summary.contains("first")));
1316        assert!(Session::resume(&home, "missing").is_err());
1317
1318        let file = fs::read_to_string(resumed.path).expect("session file");
1319        assert!(file.lines().count() >= 3);
1320        assert!(!file.contains("TEST_KEY_VALUE"));
1321        fs::remove_dir_all(home).expect("remove temp home");
1322    }
1323
1324    #[test]
1325    fn compaction_appends_a_boundary_and_reconstructs_only_retained_messages() {
1326        let home = temporary_home();
1327        let cwd = std::env::current_dir().expect("cwd");
1328        let llm = LlmSettings {
1329            base_url: "http://localhost".to_owned(),
1330            model: "model".to_owned(),
1331            api_key_env: "LUCY_COMPACTION_KEY".to_owned(),
1332            effort: None,
1333        };
1334        let mut session =
1335            Session::create_with_secret(&home, &cwd, "stable prompt".to_owned(), llm, None)
1336                .expect("create");
1337        session
1338            .append_message(ChatMessage::user("old request".to_owned()))
1339            .expect("old user");
1340        session
1341            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1342            .expect("old assistant");
1343        session
1344            .append_message(ChatMessage::user("recent request".to_owned()))
1345            .expect("recent user");
1346        session
1347            .append_message(ChatMessage::assistant(
1348                "recent answer".to_owned(),
1349                Vec::new(),
1350            ))
1351            .expect("recent assistant");
1352
1353        session
1354            .append_compaction("old work summary".to_owned(), 2, 123)
1355            .expect("append compaction");
1356
1357        let provider_messages = session.provider_messages();
1358        assert_eq!(provider_messages[0].role, "system");
1359        assert_eq!(provider_messages[1].role, "user");
1360        assert!(provider_messages[1]
1361            .content
1362            .as_deref()
1363            .is_some_and(|content| content.contains("old work summary")));
1364        let provider_text = provider_messages
1365            .iter()
1366            .filter_map(|message| message.content.as_deref())
1367            .collect::<Vec<_>>()
1368            .join("\n");
1369        assert!(!provider_text.contains("old request"));
1370        assert!(!provider_text.contains("old answer"));
1371        assert!(provider_text.contains("recent request"));
1372        assert!(provider_text.contains("recent answer"));
1373        assert!(matches!(
1374            session.history.last(),
1375            Some(SessionHistoryRecord::Compaction(CompactionRecord {
1376                first_kept_message: 2,
1377                tokens_before: 123,
1378                ..
1379            }))
1380        ));
1381
1382        let resumed = Session::resume(&home, &session.id).expect("resume");
1383        assert_eq!(resumed.provider_messages(), provider_messages);
1384        assert_eq!(resumed.messages.len(), 4, "history remains append-only");
1385        fs::remove_dir_all(home).expect("cleanup");
1386    }
1387
1388    #[test]
1389    fn compaction_rejects_a_secret_in_the_summary_without_appending() {
1390        let home = temporary_home();
1391        let cwd = std::env::current_dir().expect("cwd");
1392        let key_env = format!("LUCY_COMPACTION_SECRET_{}", std::process::id());
1393        let secret = "provider-secret";
1394        std::env::set_var(&key_env, secret);
1395        let llm = LlmSettings {
1396            base_url: "http://localhost".to_owned(),
1397            model: "model".to_owned(),
1398            api_key_env: key_env.clone(),
1399            effort: None,
1400        };
1401        let mut session =
1402            Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, Some(secret))
1403                .expect("create");
1404        session
1405            .append_message(ChatMessage::user("one".to_owned()))
1406            .expect("user");
1407        let before = fs::read_to_string(&session.path).expect("session bytes");
1408
1409        let error = session
1410            .append_compaction(secret.to_owned(), 0, 1)
1411            .expect_err("secret summary should be rejected");
1412        assert!(error.to_string().contains("session record rejected"));
1413        assert_eq!(
1414            fs::read_to_string(&session.path).expect("session bytes"),
1415            before
1416        );
1417        assert!(!session
1418            .history
1419            .iter()
1420            .any(|record| matches!(record, SessionHistoryRecord::Compaction(_))));
1421
1422        std::env::remove_var(key_env);
1423        fs::remove_dir_all(home).expect("cleanup");
1424    }
1425
1426    #[test]
1427    fn reasoning_details_round_trip_through_session_and_provider_history() {
1428        let home = temporary_home();
1429        let cwd = std::env::current_dir().expect("cwd");
1430        let llm = LlmSettings {
1431            base_url: "http://localhost".to_owned(),
1432            model: "model".to_owned(),
1433            api_key_env: "LUCY_REASONING_DETAILS_KEY".to_owned(),
1434            effort: None,
1435        };
1436        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1437            .expect("create");
1438        let details = vec![serde_json::json!({
1439            "type": "reasoning.text",
1440            "text": "provider detail"
1441        })];
1442        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1443        assistant.reasoning_details = Some(details.clone());
1444        session.append_message(assistant).expect("assistant");
1445
1446        let resumed = Session::resume(&home, &session.id).expect("resume");
1447        assert_eq!(resumed.messages[0].reasoning_details, Some(details.clone()));
1448        let provider_assistant = resumed
1449            .provider_messages()
1450            .into_iter()
1451            .find(|message| message.role == "assistant")
1452            .expect("provider assistant");
1453        assert_eq!(provider_assistant.reasoning_details, Some(details));
1454        fs::remove_dir_all(home).expect("remove temp home");
1455    }
1456
1457    #[test]
1458    fn append_rejects_secrets_nested_in_reasoning_details() {
1459        let home = temporary_home();
1460        let cwd = std::env::current_dir().expect("cwd");
1461        let llm = LlmSettings {
1462            base_url: "http://localhost".to_owned(),
1463            model: "model".to_owned(),
1464            api_key_env: "LUCY_REASONING_SECRET_KEY".to_owned(),
1465            effort: None,
1466        };
1467        let mut session = Session::create_with_secret(
1468            &home,
1469            &cwd,
1470            "prompt".to_owned(),
1471            llm,
1472            Some("provider-secret"),
1473        )
1474        .expect("create");
1475        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1476        assistant.reasoning_details = Some(vec![serde_json::json!({
1477            "type": "reasoning.text",
1478            "text": "provider-secret"
1479        })]);
1480        let error = session
1481            .append_message(assistant)
1482            .expect_err("secret reasoning details");
1483        assert_eq!(error.to_string(), "session record rejected");
1484        fs::remove_dir_all(home).expect("remove temp home");
1485    }
1486
1487    #[test]
1488    fn interruption_records_are_valid_and_resume_in_file_order_without_provider_fragments() {
1489        let home = temporary_home();
1490        let cwd = std::env::current_dir().expect("cwd");
1491        let llm = LlmSettings {
1492            base_url: "http://localhost".to_owned(),
1493            model: "model".to_owned(),
1494            api_key_env: "LUCY_NO_SESSION_KEY".to_owned(),
1495            effort: None,
1496        };
1497        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1498            .expect("create");
1499        session
1500            .append_message(ChatMessage::user("hello".to_owned()))
1501            .expect("user");
1502        session
1503            .append_interruption(InterruptionRecord {
1504                timestamp: 0,
1505                reason: "user_cancelled".to_owned(),
1506                phase: "provider_stream".to_owned(),
1507                assistant_text: "partial answer".to_owned(),
1508                tool_calls: vec![ChatToolCall {
1509                    id: "partial-call".to_owned(),
1510                    name: "cmd".to_owned(),
1511                    arguments: "{\"command\":".to_owned(),
1512                }],
1513                tool_results: Vec::new(),
1514            })
1515            .expect("interruption");
1516
1517        session
1518            .append_message(ChatMessage::assistant(
1519                String::new(),
1520                vec![ChatToolCall {
1521                    id: "call-1".to_owned(),
1522                    name: "cmd".to_owned(),
1523                    arguments: r#"{"command":"sleep 1"}"#.to_owned(),
1524                }],
1525            ))
1526            .expect("assistant tool call");
1527        session
1528            .append_interruption(InterruptionRecord {
1529                timestamp: 0,
1530                reason: "user_cancelled".to_owned(),
1531                phase: "cmd".to_owned(),
1532                assistant_text: String::new(),
1533                tool_calls: Vec::new(),
1534                tool_results: vec![SessionToolResult {
1535                    id: "call-1".to_owned(),
1536                    name: "cmd".to_owned(),
1537                    result: serde_json::json!({"canceled": true}),
1538                }],
1539            })
1540            .expect("command interruption");
1541
1542        let raw = fs::read_to_string(&session.path).expect("session JSONL");
1543        for line in raw.lines() {
1544            serde_json::from_str::<Value>(line).expect("valid JSONL record");
1545        }
1546        let resumed = Session::resume(&home, &session.id).expect("resume");
1547        assert_eq!(resumed.history.len(), 4);
1548        assert!(matches!(
1549            resumed.history[0],
1550            SessionHistoryRecord::Message { .. }
1551        ));
1552        assert!(matches!(
1553            resumed.history[1],
1554            SessionHistoryRecord::Interruption { .. }
1555        ));
1556        assert_eq!(resumed.messages.len(), 2);
1557        let provider_messages = resumed.provider_messages();
1558        assert_eq!(provider_messages.len(), 4);
1559        assert!(provider_messages.iter().any(|message| {
1560            message.role == "tool" && message.tool_call_id.as_deref() == Some("call-1")
1561        }));
1562        assert!(!resumed.provider_messages().iter().any(|message| {
1563            message
1564                .tool_calls
1565                .iter()
1566                .any(|call| call.id == "partial-call")
1567        }));
1568        fs::remove_dir_all(home).expect("remove temp home");
1569    }
1570}