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