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        // Remove tool calls that have no corresponding tool result. When a
728        // turn is interrupted (e.g. timeout, cancellation) after the assistant
729        // message was persisted but before the tool result, the API rejects
730        // the replayed request with "No tool output found for function call".
731        // Dropping the orphaned tool call lets the session resume cleanly.
732        for message in &mut messages {
733            if message.role == "assistant" && !message.tool_calls.is_empty() {
734                message
735                    .tool_calls
736                    .retain(|call| completed_tool_calls.contains(&call.id));
737            }
738        }
739        messages
740    }
741
742    pub fn list(home: &Path) -> Result<Vec<SessionMetadata>, SessionError> {
743        Self::list_with_secret(home, None)
744    }
745
746    pub fn list_with_secret(
747        home: &Path,
748        external_secret: Option<&str>,
749    ) -> Result<Vec<SessionMetadata>, SessionError> {
750        let directory = sessions_dir(home);
751        let lucy_directory = lucy_dir(home);
752        ensure_not_symlink(&lucy_directory)?;
753        if lucy_directory.is_dir() {
754            ensure_private_dir(&lucy_directory)?;
755        }
756        ensure_not_symlink(&directory)?;
757        if directory.is_dir() {
758            ensure_private_dir(&directory)?;
759        }
760        let entries = match fs::read_dir(&directory) {
761            Ok(entries) => entries,
762            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
763            Err(_error) => return Err(SessionError::new("unable to list sessions")),
764        };
765
766        let mut paths = Vec::new();
767        for entry in entries {
768            let entry = entry?;
769            let path = entry.path();
770            let metadata = match fs::symlink_metadata(&path) {
771                Ok(metadata) => metadata,
772                Err(_) => continue,
773            };
774            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
775                && metadata.is_file()
776            {
777                paths.push(path);
778            }
779        }
780        paths.sort();
781
782        let mut metadata = Vec::new();
783        for path in paths {
784            let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
785                continue;
786            };
787            let Ok(session) = Self::resume_with_secret(home, id, external_secret) else {
788                continue;
789            };
790            metadata.push(SessionMetadata {
791                record_type: "session_metadata",
792                session_id: session.id,
793                created_at: session.created_at,
794                updated_at: session.updated_at,
795                first_message: session.messages.first().map(safe_message_summary),
796                last_message: session.messages.last().map(safe_message_summary),
797            });
798        }
799        Ok(metadata)
800    }
801}
802
803struct DuplicateKeyValue(Value);
804
805impl<'de> Deserialize<'de> for DuplicateKeyValue {
806    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
807    where
808        D: Deserializer<'de>,
809    {
810        deserializer
811            .deserialize_any(DuplicateKeyValueVisitor)
812            .map(Self)
813    }
814}
815
816struct DuplicateKeyValueSeed;
817
818impl<'de> DeserializeSeed<'de> for DuplicateKeyValueSeed {
819    type Value = Value;
820
821    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
822    where
823        D: Deserializer<'de>,
824    {
825        deserializer.deserialize_any(DuplicateKeyValueVisitor)
826    }
827}
828
829struct DuplicateKeyValueVisitor;
830
831impl<'de> Visitor<'de> for DuplicateKeyValueVisitor {
832    type Value = Value;
833
834    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
835        formatter.write_str("a valid JSON value")
836    }
837
838    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
839        Ok(Value::Bool(value))
840    }
841
842    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
843        Ok(Value::Number(value.into()))
844    }
845
846    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
847    where
848        E: de::Error,
849    {
850        serde_json::Number::from_i128(value)
851            .map(Value::Number)
852            .ok_or_else(|| de::Error::custom("JSON number out of range"))
853    }
854
855    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
856        Ok(Value::Number(value.into()))
857    }
858
859    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
860    where
861        E: de::Error,
862    {
863        serde_json::Number::from_u128(value)
864            .map(Value::Number)
865            .ok_or_else(|| de::Error::custom("JSON number out of range"))
866    }
867
868    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
869    where
870        E: de::Error,
871    {
872        Ok(serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number))
873    }
874
875    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
876        Ok(Value::String(value.to_owned()))
877    }
878
879    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
880        Ok(Value::String(value))
881    }
882
883    fn visit_none<E>(self) -> Result<Self::Value, E> {
884        Ok(Value::Null)
885    }
886
887    fn visit_unit<E>(self) -> Result<Self::Value, E> {
888        Ok(Value::Null)
889    }
890
891    fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
892    where
893        A: SeqAccess<'de>,
894    {
895        let mut values = Vec::new();
896        while let Some(value) = access.next_element_seed(DuplicateKeyValueSeed)? {
897            values.push(value);
898        }
899        Ok(Value::Array(values))
900    }
901
902    fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
903    where
904        A: MapAccess<'de>,
905    {
906        let mut values = serde_json::Map::new();
907        while let Some(key) = access.next_key::<String>()? {
908            if values.contains_key(&key) {
909                return Err(de::Error::custom("duplicate object key"));
910            }
911            let value = access.next_value_seed(DuplicateKeyValueSeed)?;
912            values.insert(key, value);
913        }
914        Ok(Value::Object(values))
915    }
916}
917
918fn parse_json_value(line: &str) -> Result<Value, serde_json::Error> {
919    serde_json::from_str::<DuplicateKeyValue>(line).map(|value| value.0)
920}
921
922fn session_header_secret(raw: &[u8]) -> Option<String> {
923    let line = raw
924        .split(|byte| *byte == b'\n')
925        .find(|line| !line.iter().all(|byte| byte.is_ascii_whitespace()))?;
926    let value = parse_json_value(std::str::from_utf8(line).ok()?).ok()?;
927    let api_key_env = value
928        .get("llm")
929        .and_then(|llm| llm.get("api_key_env"))
930        .and_then(Value::as_str)?;
931    let secret = std::env::var(api_key_env).ok()?;
932    (!secret.is_empty()).then_some(secret)
933}
934
935fn bytes_contain_secret(raw: &[u8], secret: &str) -> bool {
936    let secret = secret.as_bytes();
937    !secret.is_empty() && raw.windows(secret.len()).any(|window| window == secret)
938}
939
940fn record_contains_secret(record: &SessionRecord, secret: &str) -> bool {
941    if secret.is_empty() {
942        return false;
943    }
944    if serde_json::to_vec(record)
945        .ok()
946        .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
947    {
948        return true;
949    }
950    match record {
951        SessionRecord::Session {
952            version,
953            session_id,
954            created_at,
955            cwd,
956            boot_system_prompt,
957            llm,
958            skills,
959        } => {
960            version.to_string().contains(secret)
961                || session_id.contains(secret)
962                || created_at.to_string().contains(secret)
963                || cwd.contains(secret)
964                || boot_system_prompt.contains(secret)
965                || llm.base_url.contains(secret)
966                || llm.model.contains(secret)
967                || llm.api_key_env.contains(secret)
968                || skills.iter().any(|skill| {
969                    skill.name.contains(secret)
970                        || skill.description.contains(secret)
971                        || skill.path.display().to_string().contains(secret)
972                        || skill.contents.contains(secret)
973                })
974        }
975        SessionRecord::ProviderSettings {
976            timestamp,
977            model,
978            effort,
979        } => {
980            timestamp.to_string().contains(secret)
981                || model.contains(secret)
982                || effort
983                    .as_deref()
984                    .is_some_and(|value| value.contains(secret))
985        }
986        SessionRecord::Message { timestamp, message } => {
987            timestamp.to_string().contains(secret) || message_contains_secret(message, secret)
988        }
989        SessionRecord::Interruption {
990            timestamp,
991            reason,
992            phase,
993            assistant_text,
994            tool_calls,
995            tool_results,
996        } => {
997            timestamp.to_string().contains(secret)
998                || reason.contains(secret)
999                || phase.contains(secret)
1000                || assistant_text.contains(secret)
1001                || tool_calls.iter().any(|call| {
1002                    call.id.contains(secret)
1003                        || call.name.contains(secret)
1004                        || call.arguments.contains(secret)
1005                })
1006                || tool_results.iter().any(|observation| {
1007                    observation.id.contains(secret)
1008                        || observation.name.contains(secret)
1009                        || json_value_contains_secret(&observation.result, secret)
1010                })
1011        }
1012        SessionRecord::Compaction {
1013            timestamp,
1014            summary,
1015            first_kept_message,
1016            tokens_before,
1017        } => {
1018            timestamp.to_string().contains(secret)
1019                || summary.contains(secret)
1020                || first_kept_message.to_string().contains(secret)
1021                || tokens_before.to_string().contains(secret)
1022        }
1023        SessionRecord::LegacyPendingRecord { .. } | SessionRecord::LegacyDeliveredRecord { .. } => {
1024            false
1025        }
1026    }
1027}
1028
1029fn message_contains_secret(message: &ChatMessage, secret: &str) -> bool {
1030    message.role.contains(secret)
1031        || message
1032            .content
1033            .as_deref()
1034            .is_some_and(|content| content.contains(secret))
1035        || message.reasoning_details.as_ref().is_some_and(|details| {
1036            details
1037                .iter()
1038                .any(|detail| json_value_contains_secret(detail, secret))
1039        })
1040        || message
1041            .name
1042            .as_deref()
1043            .is_some_and(|name| name.contains(secret))
1044        || message
1045            .tool_call_id
1046            .as_deref()
1047            .is_some_and(|id| id.contains(secret))
1048        || message.tool_calls.iter().any(|call| {
1049            call.id.contains(secret)
1050                || call.name.contains(secret)
1051                || call.arguments.contains(secret)
1052                || tool_arguments_contain_secret(&call.arguments, secret)
1053        })
1054}
1055
1056fn tool_arguments_contain_secret(arguments: &str, secret: &str) -> bool {
1057    serde_json::from_str::<Value>(arguments)
1058        .ok()
1059        .is_some_and(|value| json_value_contains_secret(&value, secret))
1060}
1061
1062fn json_value_contains_secret(value: &Value, secret: &str) -> bool {
1063    match value {
1064        Value::String(text) => text.contains(secret),
1065        Value::Array(values) => values
1066            .iter()
1067            .any(|value| json_value_contains_secret(value, secret)),
1068        Value::Object(object) => {
1069            object.keys().any(|key| key.contains(secret))
1070                || object
1071                    .values()
1072                    .any(|value| json_value_contains_secret(value, secret))
1073        }
1074        Value::Number(number) => number.to_string().contains(secret),
1075        Value::Bool(_) | Value::Null => false,
1076    }
1077}
1078
1079fn session_error(message: impl Into<String>, secret: Option<&str>) -> SessionError {
1080    let message = message.into();
1081    SessionError::new(redact_secret(&message, secret))
1082}
1083
1084fn session_header_rejected(secret: &str) -> SessionError {
1085    session_error("session header rejected", Some(secret))
1086}
1087
1088fn session_record_rejected(secret: &str) -> SessionError {
1089    session_error("session record rejected", Some(secret))
1090}
1091
1092fn open_session_for_append(path: &Path) -> Result<File, SessionError> {
1093    let mut options = OpenOptions::new();
1094    options.write(true).append(true);
1095    #[cfg(unix)]
1096    options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
1097    #[cfg(not(unix))]
1098    ensure_not_symlink(path)?;
1099
1100    let file = options.open(path)?;
1101    let metadata = file.metadata()?;
1102    if !metadata.is_file() {
1103        return Err(SessionError::new(
1104            "session file is not a regular private file",
1105        ));
1106    }
1107    #[cfg(unix)]
1108    if metadata.permissions().mode() & 0o777 != 0o600 {
1109        return Err(SessionError::new(
1110            "session file is not a regular private file",
1111        ));
1112    }
1113    Ok(file)
1114}
1115
1116fn write_json_record<T: Serialize>(file: &mut File, record: &T) -> Result<(), SessionError> {
1117    let line = serde_json::to_string(record)
1118        .map_err(|error| SessionError::new(format!("unable to encode session record: {error}")))?;
1119    file.write_all(line.as_bytes())?;
1120    file.write_all(b"\n")?;
1121    file.flush()?;
1122    Ok(())
1123}
1124
1125fn write_record(file: &mut File, record: &SessionRecord) -> Result<(), SessionError> {
1126    write_json_record(file, record)
1127}
1128
1129const COMPACTION_SUMMARY_PREFIX: &str = "<context_compaction>\nThe earlier conversation was compacted. Treat the following summary as authoritative context for the continued turn.\n\n";
1130const COMPACTION_SUMMARY_SUFFIX: &str = "\n</context_compaction>";
1131
1132fn compaction_summary_message(summary: &str) -> ChatMessage {
1133    ChatMessage::user(format!(
1134        "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
1135    ))
1136}
1137
1138fn safe_message_summary(message: &ChatMessage) -> String {
1139    let role = message.role.as_str();
1140    let text = message
1141        .content
1142        .as_deref()
1143        .or_else(|| message.tool_calls.first().map(|call| call.name.as_str()))
1144        .unwrap_or("");
1145    let mut summary = text.chars().take(120).collect::<String>();
1146    if text.chars().count() > 120 {
1147        summary.push('…');
1148    }
1149    format!("{role}: {summary}")
1150}
1151
1152pub fn sessions_dir(home: &Path) -> PathBuf {
1153    home.join(".lucy").join("sessions")
1154}
1155
1156pub fn validate_session_id(id: &str) -> Result<(), SessionError> {
1157    if id.is_empty()
1158        || !id.chars().all(|character| {
1159            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
1160        })
1161    {
1162        return Err(SessionError::new("session id contains invalid characters"));
1163    }
1164    Ok(())
1165}
1166
1167fn new_session_id() -> String {
1168    let timestamp = now();
1169    let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
1170    format!("{timestamp}-{}-{counter}", std::process::id())
1171}
1172
1173fn now() -> u64 {
1174    SystemTime::now()
1175        .duration_since(UNIX_EPOCH)
1176        .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64)
1177        .unwrap_or(0)
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183    use crate::config::LlmSettings;
1184    #[cfg(unix)]
1185    use std::ffi::CString;
1186    #[cfg(unix)]
1187    use std::os::unix::ffi::OsStrExt;
1188    #[cfg(unix)]
1189    use std::os::unix::fs::{symlink, PermissionsExt};
1190    use std::sync::atomic::{AtomicU64, Ordering};
1191    use std::time::{SystemTime, UNIX_EPOCH};
1192
1193    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1194
1195    fn temporary_home() -> PathBuf {
1196        loop {
1197            let stamp = SystemTime::now()
1198                .duration_since(UNIX_EPOCH)
1199                .expect("clock")
1200                .as_nanos();
1201            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1202            let path = std::env::temp_dir().join(format!(
1203                "lucy-session-{stamp}-{}-{counter}",
1204                std::process::id()
1205            ));
1206            match fs::create_dir(&path) {
1207                Ok(()) => return path,
1208                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1209                Err(error) => panic!("temp home: {error}"),
1210            }
1211        }
1212    }
1213
1214    #[cfg(unix)]
1215    #[test]
1216    fn append_rejects_a_non_private_opened_session_file_without_chmod() {
1217        let home = temporary_home();
1218        let cwd = std::env::current_dir().expect("cwd");
1219        let llm = LlmSettings {
1220            base_url: "http://localhost".to_owned(),
1221            model: "model".to_owned(),
1222            api_key_env: "LUCY_APPEND_TEST_KEY".to_owned(),
1223            effort: None,
1224        };
1225        let mut session =
1226            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1227        fs::set_permissions(&session.path, fs::Permissions::from_mode(0o644))
1228            .expect("make session group-readable");
1229
1230        let error = session
1231            .append_message(ChatMessage::user("must not append".to_owned()))
1232            .expect_err("unsafe permissions should be rejected");
1233        assert!(error.to_string().contains("private"));
1234        assert_eq!(
1235            fs::metadata(&session.path)
1236                .expect("session metadata")
1237                .permissions()
1238                .mode()
1239                & 0o777,
1240            0o644
1241        );
1242
1243        fs::remove_dir_all(home).expect("remove temp home");
1244    }
1245
1246    #[cfg(unix)]
1247    #[test]
1248    fn append_rejects_a_symlinked_session_path() {
1249        let home = temporary_home();
1250        let cwd = std::env::current_dir().expect("cwd");
1251        let llm = LlmSettings {
1252            base_url: "http://localhost".to_owned(),
1253            model: "model".to_owned(),
1254            api_key_env: "LUCY_APPEND_LINK_KEY".to_owned(),
1255            effort: None,
1256        };
1257        let mut session =
1258            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1259        let target = home.join("append-target.jsonl");
1260        fs::write(&target, "target\n").expect("target file");
1261        fs::remove_file(&session.path).expect("remove session path");
1262        symlink(&target, &session.path).expect("session symlink");
1263
1264        session
1265            .append_message(ChatMessage::user("must not append".to_owned()))
1266            .expect_err("symlink should be rejected");
1267        assert_eq!(
1268            fs::read_to_string(&target).expect("target contents"),
1269            "target\n"
1270        );
1271
1272        fs::remove_dir_all(home).expect("remove temp home");
1273    }
1274
1275    #[cfg(unix)]
1276    #[test]
1277    fn append_rejects_a_fifo_without_blocking() {
1278        let home = temporary_home();
1279        let cwd = std::env::current_dir().expect("cwd");
1280        let llm = LlmSettings {
1281            base_url: "http://localhost".to_owned(),
1282            model: "model".to_owned(),
1283            api_key_env: "LUCY_APPEND_FIFO_KEY".to_owned(),
1284            effort: None,
1285        };
1286        let mut session =
1287            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1288        fs::remove_file(&session.path).expect("remove session path");
1289        let fifo_path = CString::new(session.path.as_os_str().as_bytes()).expect("FIFO path");
1290        let result = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) };
1291        assert_eq!(result, 0, "mkfifo: {:?}", io::Error::last_os_error());
1292
1293        session
1294            .append_message(ChatMessage::user("must not append".to_owned()))
1295            .expect_err("FIFO should be rejected without a blocking open");
1296
1297        fs::remove_dir_all(home).expect("remove temp home");
1298    }
1299
1300    #[cfg(unix)]
1301    #[test]
1302    fn rejects_symlinked_session_files_and_directories() {
1303        let home = temporary_home();
1304        let directory = home.join(".lucy/sessions");
1305        fs::create_dir_all(&directory).expect("sessions directory");
1306        let target = home.join("session-target.jsonl");
1307        fs::write(&target, "not a session\n").expect("target session");
1308        let path = directory.join("linked.jsonl");
1309        symlink(&target, &path).expect("session symlink");
1310        assert!(Session::resume(&home, "linked").is_err());
1311        assert!(Session::list(&home).expect("list sessions").is_empty());
1312        fs::remove_file(path).expect("remove session symlink");
1313        fs::remove_file(target).expect("remove target session");
1314        fs::remove_dir_all(home).expect("remove temp home");
1315
1316        let home = temporary_home();
1317        let lucy = home.join(".lucy");
1318        fs::create_dir(&lucy).expect("Lucy directory");
1319        let target = home.join("sessions-target");
1320        fs::create_dir(&target).expect("target sessions directory");
1321        symlink(&target, lucy.join("sessions")).expect("sessions directory symlink");
1322        assert!(Session::list(&home).is_err());
1323        fs::remove_file(lucy.join("sessions")).expect("remove sessions directory symlink");
1324        fs::remove_dir(target).expect("remove target sessions directory");
1325        fs::remove_dir(lucy).expect("remove Lucy directory");
1326        fs::remove_dir(home).expect("remove temp home");
1327    }
1328
1329    #[test]
1330    fn resume_rejects_duplicate_header_as_an_invalid_record() {
1331        let home = temporary_home();
1332        let sessions = home.join(".lucy/sessions");
1333        fs::create_dir_all(&sessions).expect("sessions");
1334        let id = "duplicate-header";
1335        let environment = format!("LUCY_DUPLICATE_HEADER_{}", std::process::id());
1336        let secret = "provider-secret";
1337        std::env::set_var(&environment, secret);
1338        let header = format!(
1339            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}"}}}}"#
1340        );
1341        fs::write(sessions.join(format!("{id}.jsonl")), format!("{header}\n"))
1342            .expect("duplicate header");
1343
1344        let error = Session::resume(&home, id).expect_err("duplicate header should be rejected");
1345        assert_eq!(error.to_string(), "invalid session record at line 1");
1346
1347        std::env::remove_var(environment);
1348        fs::remove_dir_all(home).expect("cleanup");
1349    }
1350
1351    #[test]
1352    fn creates_appends_resumes_and_lists_jsonl_session() {
1353        let home = temporary_home();
1354        let cwd = std::env::current_dir().expect("cwd");
1355        let llm = LlmSettings {
1356            base_url: "http://localhost:1234/api/v1".to_owned(),
1357            model: "test-model".to_owned(),
1358            api_key_env: "TEST_KEY".to_owned(),
1359            effort: None,
1360        };
1361        let mut session =
1362            Session::create(&home, &cwd, "stable prompt".to_owned(), llm.clone()).expect("create");
1363        #[cfg(unix)]
1364        {
1365            use std::os::unix::fs::PermissionsExt;
1366            assert_eq!(
1367                fs::metadata(sessions_dir(&home))
1368                    .expect("sessions directory metadata")
1369                    .permissions()
1370                    .mode()
1371                    & 0o777,
1372                0o700
1373            );
1374            assert_eq!(
1375                fs::metadata(&session.path)
1376                    .expect("session file metadata")
1377                    .permissions()
1378                    .mode()
1379                    & 0o777,
1380                0o600
1381            );
1382        }
1383        let id = session.id.clone();
1384        session
1385            .append_message(ChatMessage::user("first".to_owned()))
1386            .expect("append user");
1387        session
1388            .append_message(ChatMessage::assistant("last".to_owned(), Vec::new()))
1389            .expect("append assistant");
1390
1391        let resumed = Session::resume(&home, &id).expect("resume");
1392        assert_eq!(resumed.boot_system_prompt, "stable prompt");
1393        assert_eq!(resumed.llm, llm);
1394        assert_eq!(resumed.messages.len(), 2);
1395        assert_eq!(resumed.cwd, fs::canonicalize(cwd).expect("canonical cwd"));
1396        let listed = Session::list(&home).expect("list");
1397        assert_eq!(listed.len(), 1);
1398        assert_eq!(listed[0].session_id, id);
1399        assert!(listed[0]
1400            .first_message
1401            .as_deref()
1402            .is_some_and(|summary| summary.contains("first")));
1403        assert!(Session::resume(&home, "missing").is_err());
1404
1405        let file = fs::read_to_string(resumed.path).expect("session file");
1406        assert!(file.lines().count() >= 3);
1407        assert!(!file.contains("TEST_KEY_VALUE"));
1408        fs::remove_dir_all(home).expect("remove temp home");
1409    }
1410
1411    #[test]
1412    fn resume_retains_historical_boot_system_prompt() {
1413        let home = temporary_home();
1414        let cwd = std::env::current_dir().expect("cwd");
1415        let llm = LlmSettings {
1416            base_url: "http://localhost".to_owned(),
1417            model: "model".to_owned(),
1418            api_key_env: "LUCY_HISTORICAL_PROMPT_KEY".to_owned(),
1419            effort: None,
1420        };
1421        let historical_prompt = "historical built-in boot prompt";
1422        let session =
1423            Session::create(&home, &cwd, historical_prompt.to_owned(), llm).expect("create");
1424
1425        let resumed = Session::resume(&home, &session.id).expect("resume");
1426        assert_eq!(resumed.boot_system_prompt, historical_prompt);
1427        assert_eq!(
1428            resumed.provider_messages()[0],
1429            ChatMessage::system(historical_prompt.to_owned())
1430        );
1431
1432        fs::remove_dir_all(home).expect("cleanup");
1433    }
1434
1435    #[test]
1436    fn provider_messages_downgrade_legacy_background_system_records() {
1437        let home = temporary_home();
1438        let cwd = std::env::current_dir().expect("cwd");
1439        let llm = LlmSettings {
1440            base_url: "http://localhost".to_owned(),
1441            model: "model".to_owned(),
1442            api_key_env: "LUCY_LEGACY_BACKGROUND_KEY".to_owned(),
1443            effort: None,
1444        };
1445        let mut session =
1446            Session::create(&home, &cwd, "genuine boot prompt".to_owned(), llm).expect("create");
1447        session
1448            .append_message(ChatMessage::system(format!(
1449                "{LEGACY_BACKGROUND_COMPLETION_PREFIX}. legacy output"
1450            )))
1451            .expect("append legacy background completion");
1452        let id = session.id.clone();
1453        let path = session.path.clone();
1454        let raw_before = fs::read_to_string(&path).expect("session JSONL");
1455        assert!(raw_before.contains(
1456            r#""role":"system","content":"Lucy background command completed. legacy output""#
1457        ));
1458
1459        let resumed = Session::resume(&home, &id).expect("resume");
1460        let provider_messages = resumed.provider_messages();
1461        assert_eq!(provider_messages.len(), 2);
1462        assert_eq!(provider_messages[0].role, "system");
1463        assert_eq!(
1464            provider_messages[0].content.as_deref(),
1465            Some("genuine boot prompt")
1466        );
1467        assert_eq!(provider_messages[1].role, crate::model::OBSERVATION_ROLE);
1468        assert_eq!(
1469            provider_messages[1].content.as_deref(),
1470            Some("Lucy background command completed. legacy output")
1471        );
1472        assert_eq!(
1473            fs::read_to_string(path).expect("session JSONL after provider messages"),
1474            raw_before
1475        );
1476        fs::remove_dir_all(home).expect("cleanup");
1477    }
1478
1479    #[test]
1480    fn provider_messages_drops_tool_calls_without_results() {
1481        let home = temporary_home();
1482        let cwd = std::env::current_dir().expect("cwd");
1483        let llm = LlmSettings {
1484            base_url: "http://localhost".to_owned(),
1485            model: "model".to_owned(),
1486            api_key_env: "LUCY_ORPHAN_TOOL_KEY".to_owned(),
1487            effort: None,
1488        };
1489        let mut session = Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create");
1490        // Assistant declares a tool call that never gets a result.
1491        let assistant = ChatMessage::assistant(
1492            "let me check".to_owned(),
1493            vec![ChatToolCall {
1494                id: "call-orphan".to_owned(),
1495                name: "cmd".to_owned(),
1496                arguments: r#"{"command":"pwd"}"#.to_owned(),
1497            }],
1498        );
1499        session.append_message(assistant).expect("append assistant");
1500        // A second assistant message with a completed tool call.
1501        let assistant2 = ChatMessage::assistant(
1502            "done".to_owned(),
1503            vec![ChatToolCall {
1504                id: "call-done".to_owned(),
1505                name: "cmd".to_owned(),
1506                arguments: r#"{"command":"ls"}"#.to_owned(),
1507            }],
1508        );
1509        session
1510            .append_message(assistant2)
1511            .expect("append assistant2");
1512        session
1513            .append_message(ChatMessage::tool(
1514                "call-done".to_owned(),
1515                "cmd".to_owned(),
1516                r#"{"exit_code":0,"stdout":"ok"}"#.to_owned(),
1517            ))
1518            .expect("append tool result");
1519
1520        let messages = session.provider_messages();
1521        // System + user(boot) + assistant1 + assistant2 + tool
1522        let assistants: Vec<_> = messages.iter().filter(|m| m.role == "assistant").collect();
1523        assert_eq!(assistants.len(), 2);
1524        // Orphaned tool call is removed from the first assistant.
1525        assert!(
1526            assistants[0].tool_calls.is_empty(),
1527            "orphaned tool call should be removed"
1528        );
1529        // Completed tool call is preserved on the second assistant.
1530        assert_eq!(assistants[1].tool_calls.len(), 1);
1531        assert_eq!(assistants[1].tool_calls[0].id, "call-done");
1532        fs::remove_dir_all(home).expect("cleanup");
1533    }
1534
1535    #[test]
1536    fn compaction_appends_a_boundary_and_reconstructs_only_retained_messages() {
1537        let home = temporary_home();
1538        let cwd = std::env::current_dir().expect("cwd");
1539        let llm = LlmSettings {
1540            base_url: "http://localhost".to_owned(),
1541            model: "model".to_owned(),
1542            api_key_env: "LUCY_COMPACTION_KEY".to_owned(),
1543            effort: None,
1544        };
1545        let mut session =
1546            Session::create_with_secret(&home, &cwd, "stable prompt".to_owned(), llm, None)
1547                .expect("create");
1548        session
1549            .append_message(ChatMessage::user("old request".to_owned()))
1550            .expect("old user");
1551        session
1552            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1553            .expect("old assistant");
1554        session
1555            .append_message(ChatMessage::user("recent request".to_owned()))
1556            .expect("recent user");
1557        session
1558            .append_message(ChatMessage::assistant(
1559                "recent answer".to_owned(),
1560                Vec::new(),
1561            ))
1562            .expect("recent assistant");
1563
1564        session
1565            .append_compaction("old work summary".to_owned(), 2, 123)
1566            .expect("append compaction");
1567
1568        let provider_messages = session.provider_messages();
1569        assert_eq!(provider_messages[0].role, "system");
1570        assert_eq!(provider_messages[1].role, "user");
1571        assert!(provider_messages[1]
1572            .content
1573            .as_deref()
1574            .is_some_and(|content| content.contains("old work summary")));
1575        let provider_text = provider_messages
1576            .iter()
1577            .filter_map(|message| message.content.as_deref())
1578            .collect::<Vec<_>>()
1579            .join("\n");
1580        assert!(!provider_text.contains("old request"));
1581        assert!(!provider_text.contains("old answer"));
1582        assert!(provider_text.contains("recent request"));
1583        assert!(provider_text.contains("recent answer"));
1584        assert!(matches!(
1585            session.history.last(),
1586            Some(SessionHistoryRecord::Compaction(CompactionRecord {
1587                first_kept_message: 2,
1588                tokens_before: 123,
1589                ..
1590            }))
1591        ));
1592
1593        let resumed = Session::resume(&home, &session.id).expect("resume");
1594        assert_eq!(resumed.provider_messages(), provider_messages);
1595        assert_eq!(resumed.messages.len(), 4, "history remains append-only");
1596        fs::remove_dir_all(home).expect("cleanup");
1597    }
1598
1599    #[test]
1600    fn compaction_rejects_a_secret_in_the_summary_without_appending() {
1601        let home = temporary_home();
1602        let cwd = std::env::current_dir().expect("cwd");
1603        let key_env = format!("LUCY_COMPACTION_SECRET_{}", std::process::id());
1604        let secret = "provider-secret";
1605        std::env::set_var(&key_env, secret);
1606        let llm = LlmSettings {
1607            base_url: "http://localhost".to_owned(),
1608            model: "model".to_owned(),
1609            api_key_env: key_env.clone(),
1610            effort: None,
1611        };
1612        let mut session =
1613            Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, Some(secret))
1614                .expect("create");
1615        session
1616            .append_message(ChatMessage::user("one".to_owned()))
1617            .expect("user");
1618        let before = fs::read_to_string(&session.path).expect("session bytes");
1619
1620        let error = session
1621            .append_compaction(secret.to_owned(), 0, 1)
1622            .expect_err("secret summary should be rejected");
1623        assert!(error.to_string().contains("session record rejected"));
1624        assert_eq!(
1625            fs::read_to_string(&session.path).expect("session bytes"),
1626            before
1627        );
1628        assert!(!session
1629            .history
1630            .iter()
1631            .any(|record| matches!(record, SessionHistoryRecord::Compaction(_))));
1632
1633        std::env::remove_var(key_env);
1634        fs::remove_dir_all(home).expect("cleanup");
1635    }
1636
1637    #[test]
1638    fn reasoning_details_round_trip_through_session_and_provider_history() {
1639        let home = temporary_home();
1640        let cwd = std::env::current_dir().expect("cwd");
1641        let llm = LlmSettings {
1642            base_url: "http://localhost".to_owned(),
1643            model: "model".to_owned(),
1644            api_key_env: "LUCY_REASONING_DETAILS_KEY".to_owned(),
1645            effort: None,
1646        };
1647        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1648            .expect("create");
1649        let details = vec![serde_json::json!({
1650            "type": "reasoning.text",
1651            "text": "provider detail"
1652        })];
1653        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1654        assistant.reasoning_details = Some(details.clone());
1655        session.append_message(assistant).expect("assistant");
1656
1657        let resumed = Session::resume(&home, &session.id).expect("resume");
1658        assert_eq!(resumed.messages[0].reasoning_details, Some(details.clone()));
1659        let provider_assistant = resumed
1660            .provider_messages()
1661            .into_iter()
1662            .find(|message| message.role == "assistant")
1663            .expect("provider assistant");
1664        assert_eq!(provider_assistant.reasoning_details, Some(details));
1665        fs::remove_dir_all(home).expect("remove temp home");
1666    }
1667
1668    #[test]
1669    fn append_rejects_secrets_nested_in_reasoning_details() {
1670        let home = temporary_home();
1671        let cwd = std::env::current_dir().expect("cwd");
1672        let llm = LlmSettings {
1673            base_url: "http://localhost".to_owned(),
1674            model: "model".to_owned(),
1675            api_key_env: "LUCY_REASONING_SECRET_KEY".to_owned(),
1676            effort: None,
1677        };
1678        let mut session = Session::create_with_secret(
1679            &home,
1680            &cwd,
1681            "prompt".to_owned(),
1682            llm,
1683            Some("provider-secret"),
1684        )
1685        .expect("create");
1686        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1687        assistant.reasoning_details = Some(vec![serde_json::json!({
1688            "type": "reasoning.text",
1689            "text": "provider-secret"
1690        })]);
1691        let error = session
1692            .append_message(assistant)
1693            .expect_err("secret reasoning details");
1694        assert_eq!(error.to_string(), "session record rejected");
1695        fs::remove_dir_all(home).expect("remove temp home");
1696    }
1697
1698    #[test]
1699    fn interruption_records_are_valid_and_resume_in_file_order_without_provider_fragments() {
1700        let home = temporary_home();
1701        let cwd = std::env::current_dir().expect("cwd");
1702        let llm = LlmSettings {
1703            base_url: "http://localhost".to_owned(),
1704            model: "model".to_owned(),
1705            api_key_env: "LUCY_NO_SESSION_KEY".to_owned(),
1706            effort: None,
1707        };
1708        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1709            .expect("create");
1710        session
1711            .append_message(ChatMessage::user("hello".to_owned()))
1712            .expect("user");
1713        session
1714            .append_interruption(InterruptionRecord {
1715                timestamp: 0,
1716                reason: "user_cancelled".to_owned(),
1717                phase: "provider_stream".to_owned(),
1718                assistant_text: "partial answer".to_owned(),
1719                tool_calls: vec![ChatToolCall {
1720                    id: "partial-call".to_owned(),
1721                    name: "cmd".to_owned(),
1722                    arguments: "{\"command\":".to_owned(),
1723                }],
1724                tool_results: Vec::new(),
1725            })
1726            .expect("interruption");
1727
1728        session
1729            .append_message(ChatMessage::assistant(
1730                String::new(),
1731                vec![ChatToolCall {
1732                    id: "call-1".to_owned(),
1733                    name: "cmd".to_owned(),
1734                    arguments: r#"{"command":"sleep 1"}"#.to_owned(),
1735                }],
1736            ))
1737            .expect("assistant tool call");
1738        session
1739            .append_interruption(InterruptionRecord {
1740                timestamp: 0,
1741                reason: "user_cancelled".to_owned(),
1742                phase: "cmd".to_owned(),
1743                assistant_text: String::new(),
1744                tool_calls: Vec::new(),
1745                tool_results: vec![SessionToolResult {
1746                    id: "call-1".to_owned(),
1747                    name: "cmd".to_owned(),
1748                    result: serde_json::json!({"canceled": true}),
1749                }],
1750            })
1751            .expect("command interruption");
1752
1753        let raw = fs::read_to_string(&session.path).expect("session JSONL");
1754        for line in raw.lines() {
1755            serde_json::from_str::<Value>(line).expect("valid JSONL record");
1756        }
1757        let resumed = Session::resume(&home, &session.id).expect("resume");
1758        assert_eq!(resumed.history.len(), 4);
1759        assert!(matches!(
1760            resumed.history[0],
1761            SessionHistoryRecord::Message { .. }
1762        ));
1763        assert!(matches!(
1764            resumed.history[1],
1765            SessionHistoryRecord::Interruption { .. }
1766        ));
1767        assert_eq!(resumed.messages.len(), 2);
1768        let provider_messages = resumed.provider_messages();
1769        assert_eq!(provider_messages.len(), 4);
1770        assert!(provider_messages.iter().any(|message| {
1771            message.role == "tool" && message.tool_call_id.as_deref() == Some("call-1")
1772        }));
1773        assert!(!resumed.provider_messages().iter().any(|message| {
1774            message
1775                .tool_calls
1776                .iter()
1777                .any(|call| call.id == "partial-call")
1778        }));
1779        fs::remove_dir_all(home).expect("remove temp home");
1780    }
1781
1782    #[test]
1783    fn resume_ignores_legacy_internal_records_without_creating_relationships() {
1784        let home = temporary_home();
1785        let sessions = home.join(".lucy/sessions");
1786        fs::create_dir_all(&sessions).expect("sessions");
1787        let id = "legacy-main";
1788        let header = serde_json::json!({
1789            "record": "session",
1790            "version": 1,
1791            "session_id": id,
1792            "created_at": 1,
1793            "cwd": std::env::current_dir().expect("cwd").display().to_string(),
1794            "boot_system_prompt": "prompt",
1795            "llm": {
1796                "base_url": "http://localhost",
1797                "model": "model",
1798                "api_key_env": "LUCY_LEGACY_KEY"
1799            },
1800            "skills": []
1801        });
1802        let message = serde_json::json!({
1803            "record": "message",
1804            "timestamp": 2,
1805            "message": {"role":"user","content":"keep this"}
1806        });
1807        let legacy = serde_json::json!({
1808            "record": "background_result_pending",
1809            "timestamp": 3,
1810            "completion_id": "old-completion",
1811            "task_id": "old-task",
1812            "child_session_id": "old-child",
1813            "task": "old task",
1814            "status": "completed",
1815            "result": {"output":"old result"},
1816            "completed_at": 3
1817        });
1818        fs::write(
1819            sessions.join(format!("{id}.jsonl")),
1820            format!("{}\n{}\n{}\n", header, message, legacy),
1821        )
1822        .expect("legacy session");
1823
1824        let resumed = Session::resume(&home, id).expect("legacy session resumes");
1825        assert_eq!(resumed.messages.len(), 1);
1826        assert_eq!(resumed.messages[0].content.as_deref(), Some("keep this"));
1827        assert!(resumed
1828            .provider_messages()
1829            .iter()
1830            .all(|message| message.name.as_deref() != Some("background_result")));
1831        let listed = Session::list(&home).expect("list legacy session");
1832        assert_eq!(listed.len(), 1);
1833        assert_eq!(listed[0].session_id, id);
1834        fs::remove_dir_all(home).expect("cleanup");
1835    }
1836}