Skip to main content

lucy/
session.rs

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