Skip to main content

lucy/
session.rs

1use std::collections::HashSet;
2use std::fs::{self, File, OpenOptions};
3use std::io::{self, BufRead, BufReader, Write};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde::de::{self, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::config::{
13    ensure_not_symlink, ensure_private_dir, ensure_private_file, lucy_dir, LlmSettings,
14};
15use crate::context::SkillEntry;
16use crate::model::{ChatMessage, ChatToolCall};
17use crate::redaction::{conflicts_with_protected_literal, redact_secret};
18
19#[cfg(unix)]
20use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
21
22static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
23
24#[derive(Debug)]
25pub struct SessionError(String);
26
27impl SessionError {
28    fn new(message: impl Into<String>) -> Self {
29        Self(message.into())
30    }
31}
32
33impl std::fmt::Display for SessionError {
34    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        formatter.write_str(&self.0)
36    }
37}
38
39impl std::error::Error for SessionError {}
40
41impl From<io::Error> for SessionError {
42    fn from(_error: io::Error) -> Self {
43        Self::new("session storage error")
44    }
45}
46
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(rename_all = "snake_case")]
49pub enum ChildSessionStatus {
50    Running,
51    Completed,
52    Failed,
53    Canceled,
54    Interrupted,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "record")]
59enum ChildSessionRecord {
60    #[serde(rename = "subagent_session")]
61    Session {
62        version: u8,
63        session_id: String,
64        session_kind: String,
65        parent_session_id: String,
66        cwd: String,
67        boot_system_prompt: String,
68        llm: LlmSettings,
69        task: String,
70    },
71    #[serde(rename = "message")]
72    Message {
73        timestamp: u64,
74        message: ChatMessage,
75    },
76    #[serde(rename = "status")]
77    Status {
78        timestamp: u64,
79        status: ChildSessionStatus,
80        reason: Option<String>,
81        result: Option<Value>,
82    },
83}
84
85#[derive(Debug)]
86pub struct ChildSession {
87    pub id: String,
88    pub path: PathBuf,
89    pub parent_session_id: String,
90    pub cwd: PathBuf,
91    pub boot_system_prompt: String,
92    pub llm: LlmSettings,
93    pub task: String,
94    pub messages: Vec<ChatMessage>,
95    pub status: ChildSessionStatus,
96    secret: Option<String>,
97}
98
99impl ChildSession {
100    pub fn create(
101        home: &Path,
102        parent_session_id: &str,
103        cwd: &Path,
104        boot_system_prompt: String,
105        llm: LlmSettings,
106        task: String,
107        secret: Option<&str>,
108    ) -> Result<Self, SessionError> {
109        let cwd = fs::canonicalize(cwd)
110            .map_err(|_| SessionError::new("unable to resolve subagent cwd"))?;
111        let directory = sessions_dir(home);
112        ensure_private_dir(&lucy_dir(home))?;
113        ensure_private_dir(&directory)?;
114        let id = format!("subagent-{}", new_session_id());
115        let path = directory.join(format!("{id}.jsonl"));
116        let record = ChildSessionRecord::Session {
117            version: 1,
118            session_id: id.clone(),
119            session_kind: "subagent".to_owned(),
120            parent_session_id: parent_session_id.to_owned(),
121            cwd: cwd.display().to_string(),
122            boot_system_prompt: boot_system_prompt.clone(),
123            llm: llm.clone(),
124            task: task.clone(),
125        };
126        if let Some(secret) = secret {
127            if child_record_contains_secret(&record, secret) {
128                return Err(SessionError::new("subagent session record rejected"));
129            }
130        }
131        let mut options = OpenOptions::new();
132        options.write(true).create_new(true);
133        #[cfg(unix)]
134        options.mode(0o600);
135        let mut file = options
136            .open(&path)
137            .map_err(|_| SessionError::new("unable to create subagent session file"))?;
138        ensure_private_file(&path)?;
139        write_json_record(&mut file, &record)?;
140        write_json_record(
141            &mut file,
142            &ChildSessionRecord::Status {
143                timestamp: now(),
144                status: ChildSessionStatus::Running,
145                reason: None,
146                result: None,
147            },
148        )?;
149        Ok(Self {
150            id,
151            path,
152            parent_session_id: parent_session_id.to_owned(),
153            cwd,
154            boot_system_prompt,
155            llm,
156            task,
157            messages: Vec::new(),
158            status: ChildSessionStatus::Running,
159            secret: secret.map(str::to_owned),
160        })
161    }
162
163    pub fn append_message(&mut self, message: ChatMessage) -> Result<(), SessionError> {
164        let record = ChildSessionRecord::Message {
165            timestamp: now(),
166            message: message.clone(),
167        };
168        if self
169            .secret
170            .as_deref()
171            .is_some_and(|secret| child_record_contains_secret(&record, secret))
172        {
173            return Err(SessionError::new("subagent session record rejected"));
174        }
175        let mut file = open_session_for_append(&self.path)?;
176        write_json_record(&mut file, &record)?;
177        self.messages.push(message);
178        Ok(())
179    }
180
181    pub fn append_status(
182        &mut self,
183        status: ChildSessionStatus,
184        reason: Option<String>,
185        result: Option<Value>,
186    ) -> Result<(), SessionError> {
187        let record = ChildSessionRecord::Status {
188            timestamp: now(),
189            status,
190            reason,
191            result,
192        };
193        if self
194            .secret
195            .as_deref()
196            .is_some_and(|secret| child_record_contains_secret(&record, secret))
197        {
198            return Err(SessionError::new("subagent session record rejected"));
199        }
200        let mut file = open_session_for_append(&self.path)?;
201        write_json_record(&mut file, &record)?;
202        self.status = status;
203        Ok(())
204    }
205
206    pub fn provider_messages(&self) -> Vec<ChatMessage> {
207        let mut messages = Vec::with_capacity(self.messages.len() + 1);
208        messages.push(ChatMessage::system(self.boot_system_prompt.clone()));
209        messages.extend(self.messages.iter().cloned());
210        messages
211    }
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215#[serde(tag = "record")]
216enum SessionRecord {
217    #[serde(rename = "session")]
218    Session {
219        version: u8,
220        session_id: String,
221        created_at: u64,
222        cwd: String,
223        boot_system_prompt: String,
224        llm: LlmSettings,
225        #[serde(default)]
226        skills: Vec<SkillEntry>,
227    },
228    #[serde(rename = "provider_settings")]
229    ProviderSettings {
230        timestamp: u64,
231        model: String,
232        effort: Option<String>,
233    },
234    #[serde(rename = "message")]
235    Message {
236        timestamp: u64,
237        message: ChatMessage,
238    },
239    #[serde(rename = "interruption")]
240    Interruption {
241        timestamp: u64,
242        reason: String,
243        phase: String,
244        #[serde(default)]
245        assistant_text: String,
246        #[serde(default)]
247        tool_calls: Vec<ChatToolCall>,
248        #[serde(default)]
249        tool_results: Vec<SessionToolResult>,
250    },
251    #[serde(rename = "compaction")]
252    Compaction {
253        timestamp: u64,
254        summary: String,
255        first_kept_message: usize,
256        tokens_before: usize,
257    },
258    #[serde(rename = "background_result_pending")]
259    BackgroundResultPending(BackgroundResultPending),
260    #[serde(rename = "background_result_delivered")]
261    BackgroundResultDelivered(BackgroundResultDelivered),
262}
263
264/// A bounded, secret-safe observation retained for a canceled tool call.
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub struct SessionToolResult {
267    pub id: String,
268    pub name: String,
269    pub result: Value,
270}
271
272/// The safe observations written when a user stops an active turn.
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274pub struct InterruptionRecord {
275    #[serde(default)]
276    pub timestamp: u64,
277    pub reason: String,
278    pub phase: String,
279    #[serde(default)]
280    pub assistant_text: String,
281    #[serde(default)]
282    pub tool_calls: Vec<ChatToolCall>,
283    #[serde(default)]
284    pub tool_results: Vec<SessionToolResult>,
285}
286
287/// A durable summary boundary that lets provider context shrink without
288/// rewriting the append-only session history.
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
290pub struct CompactionRecord {
291    pub timestamp: u64,
292    pub summary: String,
293    /// Message ordinal (excluding the system prompt) at which retained context
294    /// begins. Messages before this boundary remain available for replay only.
295    pub first_kept_message: usize,
296    pub tokens_before: usize,
297}
298
299pub const BACKGROUND_RESULT_TOOL_NAME: &str = "background_result";
300
301/// The parent-owned source of truth for one terminal child result.
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
303pub struct BackgroundResultPending {
304    pub timestamp: u64,
305    pub completion_id: String,
306    pub task_id: String,
307    pub child_session_id: String,
308    pub task: String,
309    pub status: ChildSessionStatus,
310    pub result: Value,
311    pub completed_at: u64,
312}
313
314#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
315#[serde(rename_all = "snake_case")]
316pub enum BackgroundResultDelivery {
317    Synthetic,
318    WaitSubagent,
319}
320
321/// An append-only commit marker. Its history position is the provider-context
322/// position at which an automatic synthetic observation is materialized.
323#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
324pub struct BackgroundResultDelivered {
325    pub timestamp: u64,
326    pub completion_id: String,
327    pub logical_turn_id: String,
328    pub delivery: BackgroundResultDelivery,
329}
330
331/// The ordered, replayable records after a session header.
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
333#[serde(tag = "record")]
334pub enum SessionHistoryRecord {
335    #[serde(rename = "provider_settings")]
336    ProviderSettings {
337        timestamp: u64,
338        model: String,
339        effort: Option<String>,
340    },
341    #[serde(rename = "message")]
342    Message {
343        timestamp: u64,
344        message: ChatMessage,
345    },
346    #[serde(rename = "interruption")]
347    Interruption {
348        timestamp: u64,
349        reason: String,
350        phase: String,
351        assistant_text: String,
352        tool_calls: Vec<ChatToolCall>,
353        tool_results: Vec<SessionToolResult>,
354    },
355    #[serde(rename = "compaction")]
356    Compaction(CompactionRecord),
357    #[serde(rename = "background_result_pending")]
358    BackgroundResultPending(BackgroundResultPending),
359    #[serde(rename = "background_result_delivered")]
360    BackgroundResultDelivered(BackgroundResultDelivered),
361}
362
363#[derive(Debug, Clone)]
364pub struct Session {
365    pub id: String,
366    pub path: PathBuf,
367    pub cwd: PathBuf,
368    pub boot_system_prompt: String,
369    pub llm: LlmSettings,
370    /// Skills are immutable per-session just like the boot prompt, so a
371    /// resumed `/<name>` skill command cannot silently load changed files.
372    pub skills: Vec<SkillEntry>,
373    pub created_at: u64,
374    pub updated_at: u64,
375    pub messages: Vec<ChatMessage>,
376    pub history: Vec<SessionHistoryRecord>,
377    secret: Option<String>,
378}
379
380#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
381pub struct SessionMetadata {
382    #[serde(rename = "type")]
383    pub record_type: &'static str,
384    pub session_id: String,
385    pub created_at: u64,
386    pub updated_at: u64,
387    pub first_message: Option<String>,
388    pub last_message: Option<String>,
389}
390
391impl Session {
392    pub fn create(
393        home: &Path,
394        cwd: &Path,
395        boot_system_prompt: String,
396        llm: LlmSettings,
397    ) -> Result<Self, SessionError> {
398        let secret = std::env::var(&llm.api_key_env).ok();
399        Self::create_with_secret(home, cwd, boot_system_prompt, llm, secret.as_deref())
400    }
401
402    pub fn create_with_secret(
403        home: &Path,
404        cwd: &Path,
405        boot_system_prompt: String,
406        llm: LlmSettings,
407        secret: Option<&str>,
408    ) -> Result<Self, SessionError> {
409        Self::create_with_skills_and_secret(home, cwd, boot_system_prompt, llm, Vec::new(), secret)
410    }
411
412    pub fn create_with_skills_and_secret(
413        home: &Path,
414        cwd: &Path,
415        boot_system_prompt: String,
416        llm: LlmSettings,
417        skills: Vec<SkillEntry>,
418        secret: Option<&str>,
419    ) -> Result<Self, SessionError> {
420        let cwd = fs::canonicalize(cwd)
421            .map_err(|_error| SessionError::new("unable to resolve session cwd"))?;
422        let sessions_directory = sessions_dir(home);
423        ensure_private_dir(&lucy_dir(home))?;
424        ensure_private_dir(&sessions_directory)?;
425        let created_at = now();
426
427        if let Some(secret) = secret {
428            if conflicts_with_protected_literal(secret) {
429                return Err(session_header_rejected(secret));
430            }
431        }
432
433        for _ in 0..16 {
434            let id = new_session_id();
435            let path = sessions_directory.join(format!("{id}.jsonl"));
436            let record = SessionRecord::Session {
437                version: 1,
438                session_id: id.clone(),
439                created_at,
440                cwd: cwd.display().to_string(),
441                boot_system_prompt: boot_system_prompt.clone(),
442                llm: llm.clone(),
443                skills: skills.clone(),
444            };
445            if let Some(secret) = secret {
446                if record_contains_secret(&record, secret) {
447                    return Err(session_header_rejected(secret));
448                }
449            }
450
451            let mut options = OpenOptions::new();
452            options.write(true).create_new(true);
453            #[cfg(unix)]
454            {
455                use std::os::unix::fs::OpenOptionsExt;
456                options.mode(0o600);
457            }
458            match options.open(&path) {
459                Ok(mut file) => {
460                    ensure_private_file(&path)?;
461                    write_record(&mut file, &record)?;
462                    return Ok(Self {
463                        id,
464                        path,
465                        cwd,
466                        boot_system_prompt,
467                        llm,
468                        skills,
469                        created_at,
470                        updated_at: created_at,
471                        messages: Vec::new(),
472                        history: Vec::new(),
473                        secret: secret.map(str::to_owned),
474                    });
475                }
476                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
477                Err(_error) => return Err(SessionError::new("unable to create session file")),
478            }
479        }
480        Err(SessionError::new("unable to allocate a unique session id"))
481    }
482
483    pub fn resume(home: &Path, id: &str) -> Result<Self, SessionError> {
484        Self::resume_with_secret(home, id, None)
485    }
486
487    pub fn resume_with_secret(
488        home: &Path,
489        id: &str,
490        external_secret: Option<&str>,
491    ) -> Result<Self, SessionError> {
492        validate_session_id(id)?;
493        let directory = sessions_dir(home);
494        let lucy_directory = lucy_dir(home);
495        ensure_not_symlink(&lucy_directory)?;
496        if lucy_directory.is_dir() {
497            ensure_private_dir(&lucy_directory)?;
498        }
499        ensure_not_symlink(&directory)?;
500        if directory.is_dir() {
501            ensure_private_dir(&directory)?;
502        }
503        let path = directory.join(format!("{id}.jsonl"));
504        ensure_not_symlink(&path)?;
505        if !path.is_file() {
506            return Err(SessionError::new("session not found"));
507        }
508
509        ensure_private_file(&path)?;
510        let raw =
511            fs::read(&path).map_err(|_error| SessionError::new("unable to read session file"))?;
512        let active_secret = external_secret
513            .map(str::to_owned)
514            .or_else(|| session_header_secret(&raw));
515        if let Some(secret) = active_secret.as_deref() {
516            if conflicts_with_protected_literal(secret) || bytes_contain_secret(&raw, secret) {
517                return Err(session_header_rejected(secret));
518            }
519        }
520
521        let reader = BufReader::new(raw.as_slice());
522        let mut header = None;
523        let mut messages = Vec::new();
524        let mut history = Vec::new();
525        let mut updated_at = None;
526
527        for (line_number, line) in reader.lines().enumerate() {
528            let line = line.map_err(|_error| {
529                session_error("unable to read session file", active_secret.as_deref())
530            })?;
531            if line.trim().is_empty() {
532                continue;
533            }
534            let value = parse_json_value(&line).map_err(|_error| {
535                session_error(
536                    format!("invalid session record at line {}", line_number + 1),
537                    active_secret.as_deref(),
538                )
539            })?;
540            if let Some(secret) = active_secret.as_deref() {
541                if json_value_contains_secret(&value, secret) {
542                    return Err(session_header_rejected(secret));
543                }
544            }
545            let record: SessionRecord = serde_json::from_value(value).map_err(|_error| {
546                session_error(
547                    format!("invalid session record at line {}", line_number + 1),
548                    active_secret.as_deref(),
549                )
550            })?;
551            if let Some(secret) = active_secret.as_deref() {
552                if record_contains_secret(&record, secret) {
553                    return Err(session_header_rejected(secret));
554                }
555            }
556            match record {
557                SessionRecord::Session {
558                    version,
559                    session_id,
560                    created_at,
561                    cwd,
562                    boot_system_prompt,
563                    llm,
564                    skills,
565                } => {
566                    if version != 1 || session_id != id || header.is_some() {
567                        return Err(session_error(
568                            "invalid session header",
569                            active_secret.as_deref(),
570                        ));
571                    }
572                    header = Some((created_at, cwd, boot_system_prompt, llm, skills));
573                }
574                SessionRecord::ProviderSettings {
575                    timestamp,
576                    model,
577                    effort,
578                } => {
579                    if header.is_none() {
580                        return Err(session_error(
581                            "session provider settings precede header",
582                            active_secret.as_deref(),
583                        ));
584                    }
585                    updated_at = Some(timestamp);
586                    history.push(SessionHistoryRecord::ProviderSettings {
587                        timestamp,
588                        model,
589                        effort,
590                    });
591                }
592                SessionRecord::Message { timestamp, message } => {
593                    if header.is_none() {
594                        return Err(session_error(
595                            "session message precedes header",
596                            active_secret.as_deref(),
597                        ));
598                    }
599                    updated_at = Some(timestamp);
600                    history.push(SessionHistoryRecord::Message {
601                        timestamp,
602                        message: message.clone(),
603                    });
604                    messages.push(message);
605                }
606                SessionRecord::Interruption {
607                    timestamp,
608                    reason,
609                    phase,
610                    assistant_text,
611                    tool_calls,
612                    tool_results,
613                } => {
614                    if header.is_none() {
615                        return Err(session_error(
616                            "session interruption precedes header",
617                            active_secret.as_deref(),
618                        ));
619                    }
620                    updated_at = Some(timestamp);
621                    history.push(SessionHistoryRecord::Interruption {
622                        timestamp,
623                        reason,
624                        phase,
625                        assistant_text,
626                        tool_calls,
627                        tool_results,
628                    });
629                }
630                SessionRecord::Compaction {
631                    timestamp,
632                    summary,
633                    first_kept_message,
634                    tokens_before,
635                } => {
636                    if header.is_none() {
637                        return Err(session_error(
638                            "session compaction precedes header",
639                            active_secret.as_deref(),
640                        ));
641                    }
642                    updated_at = Some(timestamp);
643                    history.push(SessionHistoryRecord::Compaction(CompactionRecord {
644                        timestamp,
645                        summary,
646                        first_kept_message,
647                        tokens_before,
648                    }));
649                }
650                SessionRecord::BackgroundResultPending(pending) => {
651                    if header.is_none() {
652                        return Err(session_error(
653                            "background result precedes header",
654                            active_secret.as_deref(),
655                        ));
656                    }
657                    if history.iter().any(|record| {
658                        matches!(
659                            record,
660                            SessionHistoryRecord::BackgroundResultPending(existing)
661                                if existing.completion_id == pending.completion_id
662                        )
663                    }) {
664                        return Err(session_error(
665                            "duplicate pending background result",
666                            active_secret.as_deref(),
667                        ));
668                    }
669                    updated_at = Some(pending.timestamp);
670                    history.push(SessionHistoryRecord::BackgroundResultPending(pending));
671                }
672                SessionRecord::BackgroundResultDelivered(delivered) => {
673                    if header.is_none()
674                        || !history.iter().any(|record| {
675                            matches!(
676                                record,
677                                SessionHistoryRecord::BackgroundResultPending(pending)
678                                    if pending.completion_id == delivered.completion_id
679                            )
680                        })
681                        || history.iter().any(|record| {
682                            matches!(
683                                record,
684                                SessionHistoryRecord::BackgroundResultDelivered(existing)
685                                    if existing.completion_id == delivered.completion_id
686                            )
687                        })
688                    {
689                        return Err(session_error(
690                            "invalid delivered background result",
691                            active_secret.as_deref(),
692                        ));
693                    }
694                    updated_at = Some(delivered.timestamp);
695                    history.push(SessionHistoryRecord::BackgroundResultDelivered(delivered));
696                }
697            }
698        }
699
700        let message_count = messages.len();
701        if history.iter().any(|record| {
702            matches!(
703                record,
704                SessionHistoryRecord::Compaction(compaction)
705                    if compaction.first_kept_message > message_count
706            )
707        }) {
708            return Err(session_error(
709                "invalid compaction boundary",
710                active_secret.as_deref(),
711            ));
712        }
713
714        let Some((created_at, cwd, boot_system_prompt, llm, skills)) = header else {
715            return Err(session_error(
716                "session has no header",
717                active_secret.as_deref(),
718            ));
719        };
720        let cwd = PathBuf::from(cwd);
721        Ok(Self {
722            id: id.to_owned(),
723            path,
724            cwd,
725            boot_system_prompt,
726            llm,
727            skills,
728            created_at,
729            updated_at: updated_at.unwrap_or(created_at),
730            messages,
731            history,
732            secret: active_secret,
733        })
734    }
735
736    pub fn validate_provider_settings(
737        &self,
738        model: &str,
739        effort: Option<&str>,
740    ) -> Result<(), SessionError> {
741        let record = SessionRecord::ProviderSettings {
742            timestamp: now(),
743            model: model.to_owned(),
744            effort: effort.map(str::to_owned),
745        };
746        if let Some(secret) = self.secret.as_deref() {
747            if record_contains_secret(&record, secret) {
748                return Err(session_record_rejected(secret));
749            }
750        }
751        Ok(())
752    }
753
754    pub fn append_provider_settings(
755        &mut self,
756        model: String,
757        effort: Option<String>,
758    ) -> Result<(), SessionError> {
759        let timestamp = now();
760        let record = SessionRecord::ProviderSettings {
761            timestamp,
762            model: model.clone(),
763            effort: effort.clone(),
764        };
765        if let Some(secret) = self.secret.as_deref() {
766            if record_contains_secret(&record, secret) {
767                return Err(session_record_rejected(secret));
768            }
769        }
770        let mut file = open_session_for_append(&self.path)?;
771        write_record(&mut file, &record)?;
772        self.history.push(SessionHistoryRecord::ProviderSettings {
773            timestamp,
774            model,
775            effort,
776        });
777        self.updated_at = timestamp;
778        Ok(())
779    }
780
781    pub fn append_message(&mut self, message: ChatMessage) -> Result<(), SessionError> {
782        let timestamp = now();
783        let record = SessionRecord::Message {
784            timestamp,
785            message: message.clone(),
786        };
787        if let Some(secret) = self.secret.as_deref() {
788            if record_contains_secret(&record, secret) {
789                return Err(session_record_rejected(secret));
790            }
791        }
792        let mut file = open_session_for_append(&self.path)?;
793        write_record(&mut file, &record)?;
794        self.messages.push(message.clone());
795        self.history
796            .push(SessionHistoryRecord::Message { timestamp, message });
797        self.updated_at = timestamp;
798        Ok(())
799    }
800
801    pub fn append_interruption(
802        &mut self,
803        mut interruption: InterruptionRecord,
804    ) -> Result<(), SessionError> {
805        let timestamp = now();
806        interruption.timestamp = timestamp;
807        let record = SessionRecord::Interruption {
808            timestamp,
809            reason: interruption.reason.clone(),
810            phase: interruption.phase.clone(),
811            assistant_text: interruption.assistant_text.clone(),
812            tool_calls: interruption.tool_calls.clone(),
813            tool_results: interruption.tool_results.clone(),
814        };
815        if let Some(secret) = self.secret.as_deref() {
816            if record_contains_secret(&record, secret) {
817                return Err(session_record_rejected(secret));
818            }
819        }
820        let mut file = open_session_for_append(&self.path)?;
821        write_record(&mut file, &record)?;
822        self.history.push(SessionHistoryRecord::Interruption {
823            timestamp,
824            reason: interruption.reason,
825            phase: interruption.phase,
826            assistant_text: interruption.assistant_text,
827            tool_calls: interruption.tool_calls,
828            tool_results: interruption.tool_results,
829        });
830        self.updated_at = timestamp;
831        Ok(())
832    }
833
834    pub fn append_background_result_pending(
835        &mut self,
836        mut pending: BackgroundResultPending,
837    ) -> Result<bool, SessionError> {
838        if let Some(existing) = self.history.iter().find_map(|record| match record {
839            SessionHistoryRecord::BackgroundResultPending(existing)
840                if existing.completion_id == pending.completion_id =>
841            {
842                Some(existing)
843            }
844            _ => None,
845        }) {
846            let same_completion = existing.task_id == pending.task_id
847                && existing.child_session_id == pending.child_session_id
848                && existing.task == pending.task
849                && existing.status == pending.status
850                && existing.result == pending.result
851                && existing.completed_at == pending.completed_at;
852            return if same_completion {
853                Ok(false)
854            } else {
855                Err(SessionError::new("background result identity collision"))
856            };
857        }
858        pending.timestamp = now();
859        let record = SessionRecord::BackgroundResultPending(pending.clone());
860        if self
861            .secret
862            .as_deref()
863            .is_some_and(|secret| record_contains_secret(&record, secret))
864        {
865            return Err(session_record_rejected(
866                self.secret.as_deref().unwrap_or_default(),
867            ));
868        }
869        let mut file = open_session_for_append(&self.path)?;
870        write_record(&mut file, &record)?;
871        self.updated_at = pending.timestamp;
872        self.history
873            .push(SessionHistoryRecord::BackgroundResultPending(pending));
874        Ok(true)
875    }
876
877    pub fn append_background_result_delivered(
878        &mut self,
879        completion_id: &str,
880        logical_turn_id: String,
881        delivery: BackgroundResultDelivery,
882    ) -> Result<bool, SessionError> {
883        let has_pending = self.history.iter().any(|record| {
884            matches!(
885                record,
886                SessionHistoryRecord::BackgroundResultPending(pending)
887                    if pending.completion_id == completion_id
888            )
889        });
890        if !has_pending {
891            return Err(SessionError::new("background result has no pending record"));
892        }
893        if self.history.iter().any(|record| {
894            matches!(
895                record,
896                SessionHistoryRecord::BackgroundResultDelivered(existing)
897                    if existing.completion_id == completion_id
898            )
899        }) {
900            return Ok(false);
901        }
902        let delivered = BackgroundResultDelivered {
903            timestamp: now(),
904            completion_id: completion_id.to_owned(),
905            logical_turn_id,
906            delivery,
907        };
908        let record = SessionRecord::BackgroundResultDelivered(delivered.clone());
909        if self
910            .secret
911            .as_deref()
912            .is_some_and(|secret| record_contains_secret(&record, secret))
913        {
914            return Err(session_record_rejected(
915                self.secret.as_deref().unwrap_or_default(),
916            ));
917        }
918        let mut file = open_session_for_append(&self.path)?;
919        write_record(&mut file, &record)?;
920        self.updated_at = delivered.timestamp;
921        self.history
922            .push(SessionHistoryRecord::BackgroundResultDelivered(delivered));
923        Ok(true)
924    }
925
926    pub fn undelivered_background_results(&self) -> Vec<BackgroundResultPending> {
927        let delivered = self
928            .history
929            .iter()
930            .filter_map(|record| match record {
931                SessionHistoryRecord::BackgroundResultDelivered(delivered) => {
932                    Some(delivered.completion_id.as_str())
933                }
934                _ => None,
935            })
936            .collect::<HashSet<_>>();
937        self.history
938            .iter()
939            .filter_map(|record| match record {
940                SessionHistoryRecord::BackgroundResultPending(pending)
941                    if !delivered.contains(pending.completion_id.as_str()) =>
942                {
943                    Some(pending.clone())
944                }
945                _ => None,
946            })
947            .collect()
948    }
949
950    /// Append a summary boundary without deleting the historical records that
951    /// preceded it. `first_kept_message` counts ordinary message records from
952    /// the start of the session, excluding the boot system prompt.
953    pub fn append_compaction(
954        &mut self,
955        summary: String,
956        first_kept_message: usize,
957        tokens_before: usize,
958    ) -> Result<(), SessionError> {
959        let timestamp = now();
960        let record = SessionRecord::Compaction {
961            timestamp,
962            summary: summary.clone(),
963            first_kept_message,
964            tokens_before,
965        };
966        if let Some(secret) = self.secret.as_deref() {
967            if record_contains_secret(&record, secret) {
968                return Err(session_record_rejected(secret));
969            }
970        }
971        let mut file = open_session_for_append(&self.path)?;
972        write_record(&mut file, &record)?;
973        self.history
974            .push(SessionHistoryRecord::Compaction(CompactionRecord {
975                timestamp,
976                summary,
977                first_kept_message,
978                tokens_before,
979            }));
980        self.updated_at = timestamp;
981        Ok(())
982    }
983
984    pub fn provider_messages(&self) -> Vec<ChatMessage> {
985        let latest_compaction = self.history.iter().rev().find_map(|record| match record {
986            SessionHistoryRecord::Compaction(compaction) => Some(compaction),
987            _ => None,
988        });
989        let first_kept_message = latest_compaction.map(|compaction| compaction.first_kept_message);
990        let interruption_results = self
991            .history
992            .iter()
993            .filter_map(|record| match record {
994                SessionHistoryRecord::Interruption {
995                    phase,
996                    tool_results,
997                    ..
998                } if phase == "cmd" => Some(tool_results),
999                _ => None,
1000            })
1001            .flatten()
1002            .count();
1003        let mut messages = Vec::with_capacity(
1004            self.messages.len()
1005                + 1
1006                + interruption_results
1007                + usize::from(latest_compaction.is_some()),
1008        );
1009        let mut declared_tool_calls = HashSet::new();
1010        let mut completed_tool_calls = HashSet::new();
1011        let mut pending_background_results = std::collections::HashMap::new();
1012        messages.push(ChatMessage::system(self.boot_system_prompt.clone()));
1013        if let Some(compaction) = latest_compaction {
1014            messages.push(compaction_summary_message(&compaction.summary));
1015        }
1016
1017        let mut message_ordinal = 0usize;
1018        for record in &self.history {
1019            match record {
1020                SessionHistoryRecord::Message { message, .. } => {
1021                    let include =
1022                        first_kept_message.is_none_or(|boundary| message_ordinal >= boundary);
1023                    message_ordinal += 1;
1024                    if !include {
1025                        continue;
1026                    }
1027                    if message.role == "assistant" {
1028                        declared_tool_calls
1029                            .extend(message.tool_calls.iter().map(|call| call.id.clone()));
1030                    }
1031                    if message.role == "tool" {
1032                        if let Some(id) = message.tool_call_id.as_deref() {
1033                            completed_tool_calls.insert(id.to_owned());
1034                        }
1035                    }
1036                    messages.push(message.clone());
1037                }
1038                SessionHistoryRecord::Interruption {
1039                    phase,
1040                    tool_results,
1041                    ..
1042                } if phase == "cmd" => {
1043                    for observation in tool_results {
1044                        if !declared_tool_calls.contains(&observation.id)
1045                            || completed_tool_calls.contains(&observation.id)
1046                        {
1047                            continue;
1048                        }
1049                        let Ok(content) = serde_json::to_string(&observation.result) else {
1050                            continue;
1051                        };
1052                        messages.push(ChatMessage::tool(
1053                            observation.id.clone(),
1054                            observation.name.clone(),
1055                            content,
1056                        ));
1057                        completed_tool_calls.insert(observation.id.clone());
1058                    }
1059                }
1060                SessionHistoryRecord::BackgroundResultPending(pending) => {
1061                    pending_background_results
1062                        .insert(pending.completion_id.clone(), pending.clone());
1063                }
1064                SessionHistoryRecord::BackgroundResultDelivered(delivered)
1065                    if delivered.delivery == BackgroundResultDelivery::Synthetic
1066                        && first_kept_message
1067                            .is_none_or(|boundary| message_ordinal >= boundary) =>
1068                {
1069                    if let Some(pending) = pending_background_results.get(&delivered.completion_id)
1070                    {
1071                        messages.extend(background_result_messages(pending));
1072                    }
1073                }
1074                SessionHistoryRecord::ProviderSettings { .. }
1075                | SessionHistoryRecord::Interruption { .. }
1076                | SessionHistoryRecord::Compaction(_)
1077                | SessionHistoryRecord::BackgroundResultDelivered(_) => {}
1078            }
1079        }
1080        messages
1081    }
1082
1083    pub fn list(home: &Path) -> Result<Vec<SessionMetadata>, SessionError> {
1084        Self::list_with_secret(home, None)
1085    }
1086
1087    pub fn list_with_secret(
1088        home: &Path,
1089        external_secret: Option<&str>,
1090    ) -> Result<Vec<SessionMetadata>, SessionError> {
1091        let directory = sessions_dir(home);
1092        let lucy_directory = lucy_dir(home);
1093        ensure_not_symlink(&lucy_directory)?;
1094        if lucy_directory.is_dir() {
1095            ensure_private_dir(&lucy_directory)?;
1096        }
1097        ensure_not_symlink(&directory)?;
1098        if directory.is_dir() {
1099            ensure_private_dir(&directory)?;
1100        }
1101        let entries = match fs::read_dir(&directory) {
1102            Ok(entries) => entries,
1103            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1104            Err(_error) => return Err(SessionError::new("unable to list sessions")),
1105        };
1106
1107        let mut paths = Vec::new();
1108        for entry in entries {
1109            let entry = entry?;
1110            let path = entry.path();
1111            let metadata = match fs::symlink_metadata(&path) {
1112                Ok(metadata) => metadata,
1113                Err(_) => continue,
1114            };
1115            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
1116                && metadata.is_file()
1117            {
1118                paths.push(path);
1119            }
1120        }
1121        paths.sort();
1122
1123        let mut metadata = Vec::new();
1124        for path in paths {
1125            let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
1126                continue;
1127            };
1128            let Ok(session) = Self::resume_with_secret(home, id, external_secret) else {
1129                continue;
1130            };
1131            metadata.push(SessionMetadata {
1132                record_type: "session_metadata",
1133                session_id: session.id,
1134                created_at: session.created_at,
1135                updated_at: session.updated_at,
1136                first_message: session.messages.first().map(safe_message_summary),
1137                last_message: session.messages.last().map(safe_message_summary),
1138            });
1139        }
1140        Ok(metadata)
1141    }
1142}
1143
1144struct DuplicateKeyValue(Value);
1145
1146impl<'de> Deserialize<'de> for DuplicateKeyValue {
1147    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1148    where
1149        D: Deserializer<'de>,
1150    {
1151        deserializer
1152            .deserialize_any(DuplicateKeyValueVisitor)
1153            .map(Self)
1154    }
1155}
1156
1157struct DuplicateKeyValueSeed;
1158
1159impl<'de> DeserializeSeed<'de> for DuplicateKeyValueSeed {
1160    type Value = Value;
1161
1162    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1163    where
1164        D: Deserializer<'de>,
1165    {
1166        deserializer.deserialize_any(DuplicateKeyValueVisitor)
1167    }
1168}
1169
1170struct DuplicateKeyValueVisitor;
1171
1172impl<'de> Visitor<'de> for DuplicateKeyValueVisitor {
1173    type Value = Value;
1174
1175    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1176        formatter.write_str("a valid JSON value")
1177    }
1178
1179    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
1180        Ok(Value::Bool(value))
1181    }
1182
1183    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1184        Ok(Value::Number(value.into()))
1185    }
1186
1187    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
1188    where
1189        E: de::Error,
1190    {
1191        serde_json::Number::from_i128(value)
1192            .map(Value::Number)
1193            .ok_or_else(|| de::Error::custom("JSON number out of range"))
1194    }
1195
1196    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1197        Ok(Value::Number(value.into()))
1198    }
1199
1200    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
1201    where
1202        E: de::Error,
1203    {
1204        serde_json::Number::from_u128(value)
1205            .map(Value::Number)
1206            .ok_or_else(|| de::Error::custom("JSON number out of range"))
1207    }
1208
1209    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1210    where
1211        E: de::Error,
1212    {
1213        Ok(serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number))
1214    }
1215
1216    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
1217        Ok(Value::String(value.to_owned()))
1218    }
1219
1220    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
1221        Ok(Value::String(value))
1222    }
1223
1224    fn visit_none<E>(self) -> Result<Self::Value, E> {
1225        Ok(Value::Null)
1226    }
1227
1228    fn visit_unit<E>(self) -> Result<Self::Value, E> {
1229        Ok(Value::Null)
1230    }
1231
1232    fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
1233    where
1234        A: SeqAccess<'de>,
1235    {
1236        let mut values = Vec::new();
1237        while let Some(value) = access.next_element_seed(DuplicateKeyValueSeed)? {
1238            values.push(value);
1239        }
1240        Ok(Value::Array(values))
1241    }
1242
1243    fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
1244    where
1245        A: MapAccess<'de>,
1246    {
1247        let mut values = serde_json::Map::new();
1248        while let Some(key) = access.next_key::<String>()? {
1249            if values.contains_key(&key) {
1250                return Err(de::Error::custom("duplicate object key"));
1251            }
1252            let value = access.next_value_seed(DuplicateKeyValueSeed)?;
1253            values.insert(key, value);
1254        }
1255        Ok(Value::Object(values))
1256    }
1257}
1258
1259fn parse_json_value(line: &str) -> Result<Value, serde_json::Error> {
1260    serde_json::from_str::<DuplicateKeyValue>(line).map(|value| value.0)
1261}
1262
1263fn session_header_secret(raw: &[u8]) -> Option<String> {
1264    let line = raw
1265        .split(|byte| *byte == b'\n')
1266        .find(|line| !line.iter().all(|byte| byte.is_ascii_whitespace()))?;
1267    let value = parse_json_value(std::str::from_utf8(line).ok()?).ok()?;
1268    let api_key_env = value
1269        .get("llm")
1270        .and_then(|llm| llm.get("api_key_env"))
1271        .and_then(Value::as_str)?;
1272    let secret = std::env::var(api_key_env).ok()?;
1273    (!secret.is_empty()).then_some(secret)
1274}
1275
1276fn bytes_contain_secret(raw: &[u8], secret: &str) -> bool {
1277    let secret = secret.as_bytes();
1278    !secret.is_empty() && raw.windows(secret.len()).any(|window| window == secret)
1279}
1280
1281fn record_contains_secret(record: &SessionRecord, secret: &str) -> bool {
1282    if secret.is_empty() {
1283        return false;
1284    }
1285    if serde_json::to_vec(record)
1286        .ok()
1287        .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
1288    {
1289        return true;
1290    }
1291    match record {
1292        SessionRecord::Session {
1293            version,
1294            session_id,
1295            created_at,
1296            cwd,
1297            boot_system_prompt,
1298            llm,
1299            skills,
1300        } => {
1301            version.to_string().contains(secret)
1302                || session_id.contains(secret)
1303                || created_at.to_string().contains(secret)
1304                || cwd.contains(secret)
1305                || boot_system_prompt.contains(secret)
1306                || llm.base_url.contains(secret)
1307                || llm.model.contains(secret)
1308                || llm.api_key_env.contains(secret)
1309                || skills.iter().any(|skill| {
1310                    skill.name.contains(secret)
1311                        || skill.description.contains(secret)
1312                        || skill.path.display().to_string().contains(secret)
1313                        || skill.contents.contains(secret)
1314                })
1315        }
1316        SessionRecord::ProviderSettings {
1317            timestamp,
1318            model,
1319            effort,
1320        } => {
1321            timestamp.to_string().contains(secret)
1322                || model.contains(secret)
1323                || effort
1324                    .as_deref()
1325                    .is_some_and(|value| value.contains(secret))
1326        }
1327        SessionRecord::Message { timestamp, message } => {
1328            timestamp.to_string().contains(secret) || message_contains_secret(message, secret)
1329        }
1330        SessionRecord::Interruption {
1331            timestamp,
1332            reason,
1333            phase,
1334            assistant_text,
1335            tool_calls,
1336            tool_results,
1337        } => {
1338            timestamp.to_string().contains(secret)
1339                || reason.contains(secret)
1340                || phase.contains(secret)
1341                || assistant_text.contains(secret)
1342                || tool_calls.iter().any(|call| {
1343                    call.id.contains(secret)
1344                        || call.name.contains(secret)
1345                        || call.arguments.contains(secret)
1346                })
1347                || tool_results.iter().any(|observation| {
1348                    observation.id.contains(secret)
1349                        || observation.name.contains(secret)
1350                        || json_value_contains_secret(&observation.result, secret)
1351                })
1352        }
1353        SessionRecord::Compaction {
1354            timestamp,
1355            summary,
1356            first_kept_message,
1357            tokens_before,
1358        } => {
1359            timestamp.to_string().contains(secret)
1360                || summary.contains(secret)
1361                || first_kept_message.to_string().contains(secret)
1362                || tokens_before.to_string().contains(secret)
1363        }
1364        SessionRecord::BackgroundResultPending(pending) => {
1365            pending.timestamp.to_string().contains(secret)
1366                || pending.completion_id.contains(secret)
1367                || pending.task_id.contains(secret)
1368                || pending.child_session_id.contains(secret)
1369                || pending.task.contains(secret)
1370                || json_value_contains_secret(&pending.result, secret)
1371                || pending.completed_at.to_string().contains(secret)
1372        }
1373        SessionRecord::BackgroundResultDelivered(delivered) => {
1374            delivered.timestamp.to_string().contains(secret)
1375                || delivered.completion_id.contains(secret)
1376                || delivered.logical_turn_id.contains(secret)
1377        }
1378    }
1379}
1380
1381fn message_contains_secret(message: &ChatMessage, secret: &str) -> bool {
1382    message.role.contains(secret)
1383        || message
1384            .content
1385            .as_deref()
1386            .is_some_and(|content| content.contains(secret))
1387        || message.reasoning_details.as_ref().is_some_and(|details| {
1388            details
1389                .iter()
1390                .any(|detail| json_value_contains_secret(detail, secret))
1391        })
1392        || message
1393            .name
1394            .as_deref()
1395            .is_some_and(|name| name.contains(secret))
1396        || message
1397            .tool_call_id
1398            .as_deref()
1399            .is_some_and(|id| id.contains(secret))
1400        || message.tool_calls.iter().any(|call| {
1401            call.id.contains(secret)
1402                || call.name.contains(secret)
1403                || call.arguments.contains(secret)
1404                || tool_arguments_contain_secret(&call.arguments, secret)
1405        })
1406}
1407
1408fn tool_arguments_contain_secret(arguments: &str, secret: &str) -> bool {
1409    serde_json::from_str::<Value>(arguments)
1410        .ok()
1411        .is_some_and(|value| json_value_contains_secret(&value, secret))
1412}
1413
1414fn json_value_contains_secret(value: &Value, secret: &str) -> bool {
1415    match value {
1416        Value::String(text) => text.contains(secret),
1417        Value::Array(values) => values
1418            .iter()
1419            .any(|value| json_value_contains_secret(value, secret)),
1420        Value::Object(object) => {
1421            object.keys().any(|key| key.contains(secret))
1422                || object
1423                    .values()
1424                    .any(|value| json_value_contains_secret(value, secret))
1425        }
1426        Value::Number(number) => number.to_string().contains(secret),
1427        Value::Bool(_) | Value::Null => false,
1428    }
1429}
1430
1431fn session_error(message: impl Into<String>, secret: Option<&str>) -> SessionError {
1432    let message = message.into();
1433    SessionError::new(redact_secret(&message, secret))
1434}
1435
1436fn session_header_rejected(secret: &str) -> SessionError {
1437    session_error("session header rejected", Some(secret))
1438}
1439
1440fn session_record_rejected(secret: &str) -> SessionError {
1441    session_error("session record rejected", Some(secret))
1442}
1443
1444fn open_session_for_append(path: &Path) -> Result<File, SessionError> {
1445    let mut options = OpenOptions::new();
1446    options.write(true).append(true);
1447    #[cfg(unix)]
1448    options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
1449    #[cfg(not(unix))]
1450    ensure_not_symlink(path)?;
1451
1452    let file = options.open(path)?;
1453    let metadata = file.metadata()?;
1454    if !metadata.is_file() {
1455        return Err(SessionError::new(
1456            "session file is not a regular private file",
1457        ));
1458    }
1459    #[cfg(unix)]
1460    if metadata.permissions().mode() & 0o777 != 0o600 {
1461        return Err(SessionError::new(
1462            "session file is not a regular private file",
1463        ));
1464    }
1465    Ok(file)
1466}
1467
1468fn write_json_record<T: Serialize>(file: &mut File, record: &T) -> Result<(), SessionError> {
1469    let line = serde_json::to_string(record)
1470        .map_err(|error| SessionError::new(format!("unable to encode session record: {error}")))?;
1471    file.write_all(line.as_bytes())?;
1472    file.write_all(b"\n")?;
1473    file.flush()?;
1474    Ok(())
1475}
1476
1477fn write_record(file: &mut File, record: &SessionRecord) -> Result<(), SessionError> {
1478    write_json_record(file, record)
1479}
1480
1481fn child_record_contains_secret<T: Serialize>(record: &T, secret: &str) -> bool {
1482    !secret.is_empty()
1483        && serde_json::to_vec(record)
1484            .ok()
1485            .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
1486}
1487
1488fn background_result_messages(pending: &BackgroundResultPending) -> [ChatMessage; 2] {
1489    let call_id = format!("background-result-{}", pending.completion_id);
1490    let arguments = serde_json::json!({
1491        "completion_id": pending.completion_id,
1492        "task_id": pending.task_id,
1493        "child_session_id": pending.child_session_id,
1494    })
1495    .to_string();
1496    let content = serde_json::json!({
1497        "completion_id": pending.completion_id,
1498        "task_id": pending.task_id,
1499        "child_session_id": pending.child_session_id,
1500        "task": pending.task,
1501        "status": pending.status,
1502        "result": pending.result,
1503        "completed_at": pending.completed_at,
1504    })
1505    .to_string();
1506    [
1507        ChatMessage::assistant(
1508            String::new(),
1509            vec![ChatToolCall {
1510                id: call_id.clone(),
1511                name: BACKGROUND_RESULT_TOOL_NAME.to_owned(),
1512                arguments,
1513            }],
1514        ),
1515        ChatMessage::tool(call_id, BACKGROUND_RESULT_TOOL_NAME.to_owned(), content),
1516    ]
1517}
1518
1519const COMPACTION_SUMMARY_PREFIX: &str = "<context_compaction>\nThe earlier conversation was compacted. Treat the following summary as authoritative context for the continued turn.\n\n";
1520const COMPACTION_SUMMARY_SUFFIX: &str = "\n</context_compaction>";
1521
1522fn compaction_summary_message(summary: &str) -> ChatMessage {
1523    ChatMessage::user(format!(
1524        "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
1525    ))
1526}
1527
1528fn safe_message_summary(message: &ChatMessage) -> String {
1529    let role = message.role.as_str();
1530    let text = message
1531        .content
1532        .as_deref()
1533        .or_else(|| message.tool_calls.first().map(|call| call.name.as_str()))
1534        .unwrap_or("");
1535    let mut summary = text.chars().take(120).collect::<String>();
1536    if text.chars().count() > 120 {
1537        summary.push('…');
1538    }
1539    format!("{role}: {summary}")
1540}
1541
1542pub fn sessions_dir(home: &Path) -> PathBuf {
1543    home.join(".lucy").join("sessions")
1544}
1545
1546pub fn validate_session_id(id: &str) -> Result<(), SessionError> {
1547    if id.is_empty()
1548        || !id.chars().all(|character| {
1549            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
1550        })
1551    {
1552        return Err(SessionError::new("session id contains invalid characters"));
1553    }
1554    Ok(())
1555}
1556
1557fn new_session_id() -> String {
1558    let timestamp = now();
1559    let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
1560    format!("{timestamp}-{}-{counter}", std::process::id())
1561}
1562
1563fn now() -> u64 {
1564    SystemTime::now()
1565        .duration_since(UNIX_EPOCH)
1566        .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64)
1567        .unwrap_or(0)
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572    use super::*;
1573    use crate::config::LlmSettings;
1574    #[cfg(unix)]
1575    use std::ffi::CString;
1576    #[cfg(unix)]
1577    use std::os::unix::ffi::OsStrExt;
1578    #[cfg(unix)]
1579    use std::os::unix::fs::{symlink, PermissionsExt};
1580    use std::sync::atomic::{AtomicU64, Ordering};
1581    use std::time::{SystemTime, UNIX_EPOCH};
1582
1583    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1584
1585    fn temporary_home() -> PathBuf {
1586        loop {
1587            let stamp = SystemTime::now()
1588                .duration_since(UNIX_EPOCH)
1589                .expect("clock")
1590                .as_nanos();
1591            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1592            let path = std::env::temp_dir().join(format!(
1593                "lucy-session-{stamp}-{}-{counter}",
1594                std::process::id()
1595            ));
1596            match fs::create_dir(&path) {
1597                Ok(()) => return path,
1598                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1599                Err(error) => panic!("temp home: {error}"),
1600            }
1601        }
1602    }
1603
1604    #[cfg(unix)]
1605    #[test]
1606    fn append_rejects_a_non_private_opened_session_file_without_chmod() {
1607        let home = temporary_home();
1608        let cwd = std::env::current_dir().expect("cwd");
1609        let llm = LlmSettings {
1610            base_url: "http://localhost".to_owned(),
1611            model: "model".to_owned(),
1612            api_key_env: "LUCY_APPEND_TEST_KEY".to_owned(),
1613            effort: None,
1614        };
1615        let mut session =
1616            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1617        fs::set_permissions(&session.path, fs::Permissions::from_mode(0o644))
1618            .expect("make session group-readable");
1619
1620        let error = session
1621            .append_message(ChatMessage::user("must not append".to_owned()))
1622            .expect_err("unsafe permissions should be rejected");
1623        assert!(error.to_string().contains("private"));
1624        assert_eq!(
1625            fs::metadata(&session.path)
1626                .expect("session metadata")
1627                .permissions()
1628                .mode()
1629                & 0o777,
1630            0o644
1631        );
1632
1633        fs::remove_dir_all(home).expect("remove temp home");
1634    }
1635
1636    #[cfg(unix)]
1637    #[test]
1638    fn append_rejects_a_symlinked_session_path() {
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_APPEND_LINK_KEY".to_owned(),
1645            effort: None,
1646        };
1647        let mut session =
1648            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1649        let target = home.join("append-target.jsonl");
1650        fs::write(&target, "target\n").expect("target file");
1651        fs::remove_file(&session.path).expect("remove session path");
1652        symlink(&target, &session.path).expect("session symlink");
1653
1654        session
1655            .append_message(ChatMessage::user("must not append".to_owned()))
1656            .expect_err("symlink should be rejected");
1657        assert_eq!(
1658            fs::read_to_string(&target).expect("target contents"),
1659            "target\n"
1660        );
1661
1662        fs::remove_dir_all(home).expect("remove temp home");
1663    }
1664
1665    #[cfg(unix)]
1666    #[test]
1667    fn append_rejects_a_fifo_without_blocking() {
1668        let home = temporary_home();
1669        let cwd = std::env::current_dir().expect("cwd");
1670        let llm = LlmSettings {
1671            base_url: "http://localhost".to_owned(),
1672            model: "model".to_owned(),
1673            api_key_env: "LUCY_APPEND_FIFO_KEY".to_owned(),
1674            effort: None,
1675        };
1676        let mut session =
1677            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1678        fs::remove_file(&session.path).expect("remove session path");
1679        let fifo_path = CString::new(session.path.as_os_str().as_bytes()).expect("FIFO path");
1680        let result = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) };
1681        assert_eq!(result, 0, "mkfifo: {:?}", io::Error::last_os_error());
1682
1683        session
1684            .append_message(ChatMessage::user("must not append".to_owned()))
1685            .expect_err("FIFO should be rejected without a blocking open");
1686
1687        fs::remove_dir_all(home).expect("remove temp home");
1688    }
1689
1690    #[cfg(unix)]
1691    #[test]
1692    fn rejects_symlinked_session_files_and_directories() {
1693        let home = temporary_home();
1694        let directory = home.join(".lucy/sessions");
1695        fs::create_dir_all(&directory).expect("sessions directory");
1696        let target = home.join("session-target.jsonl");
1697        fs::write(&target, "not a session\n").expect("target session");
1698        let path = directory.join("linked.jsonl");
1699        symlink(&target, &path).expect("session symlink");
1700        assert!(Session::resume(&home, "linked").is_err());
1701        assert!(Session::list(&home).expect("list sessions").is_empty());
1702        fs::remove_file(path).expect("remove session symlink");
1703        fs::remove_file(target).expect("remove target session");
1704        fs::remove_dir_all(home).expect("remove temp home");
1705
1706        let home = temporary_home();
1707        let lucy = home.join(".lucy");
1708        fs::create_dir(&lucy).expect("Lucy directory");
1709        let target = home.join("sessions-target");
1710        fs::create_dir(&target).expect("target sessions directory");
1711        symlink(&target, lucy.join("sessions")).expect("sessions directory symlink");
1712        assert!(Session::list(&home).is_err());
1713        fs::remove_file(lucy.join("sessions")).expect("remove sessions directory symlink");
1714        fs::remove_dir(target).expect("remove target sessions directory");
1715        fs::remove_dir(lucy).expect("remove Lucy directory");
1716        fs::remove_dir(home).expect("remove temp home");
1717    }
1718
1719    #[test]
1720    fn resume_rejects_duplicate_header_as_an_invalid_record() {
1721        let home = temporary_home();
1722        let sessions = home.join(".lucy/sessions");
1723        fs::create_dir_all(&sessions).expect("sessions");
1724        let id = "duplicate-header";
1725        let environment = format!("LUCY_DUPLICATE_HEADER_{}", std::process::id());
1726        let secret = "provider-secret";
1727        std::env::set_var(&environment, secret);
1728        let header = format!(
1729            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}"}}}}"#
1730        );
1731        fs::write(sessions.join(format!("{id}.jsonl")), format!("{header}\n"))
1732            .expect("duplicate header");
1733
1734        let error = Session::resume(&home, id).expect_err("duplicate header should be rejected");
1735        assert_eq!(error.to_string(), "invalid session record at line 1");
1736
1737        std::env::remove_var(environment);
1738        fs::remove_dir_all(home).expect("cleanup");
1739    }
1740
1741    #[test]
1742    fn creates_appends_resumes_and_lists_jsonl_session() {
1743        let home = temporary_home();
1744        let cwd = std::env::current_dir().expect("cwd");
1745        let llm = LlmSettings {
1746            base_url: "http://localhost:1234/api/v1".to_owned(),
1747            model: "test-model".to_owned(),
1748            api_key_env: "TEST_KEY".to_owned(),
1749            effort: None,
1750        };
1751        let mut session =
1752            Session::create(&home, &cwd, "stable prompt".to_owned(), llm.clone()).expect("create");
1753        #[cfg(unix)]
1754        {
1755            use std::os::unix::fs::PermissionsExt;
1756            assert_eq!(
1757                fs::metadata(sessions_dir(&home))
1758                    .expect("sessions directory metadata")
1759                    .permissions()
1760                    .mode()
1761                    & 0o777,
1762                0o700
1763            );
1764            assert_eq!(
1765                fs::metadata(&session.path)
1766                    .expect("session file metadata")
1767                    .permissions()
1768                    .mode()
1769                    & 0o777,
1770                0o600
1771            );
1772        }
1773        let id = session.id.clone();
1774        session
1775            .append_message(ChatMessage::user("first".to_owned()))
1776            .expect("append user");
1777        session
1778            .append_message(ChatMessage::assistant("last".to_owned(), Vec::new()))
1779            .expect("append assistant");
1780
1781        let resumed = Session::resume(&home, &id).expect("resume");
1782        assert_eq!(resumed.boot_system_prompt, "stable prompt");
1783        assert_eq!(resumed.llm, llm);
1784        assert_eq!(resumed.messages.len(), 2);
1785        assert_eq!(resumed.cwd, fs::canonicalize(cwd).expect("canonical cwd"));
1786        let listed = Session::list(&home).expect("list");
1787        assert_eq!(listed.len(), 1);
1788        assert_eq!(listed[0].session_id, id);
1789        assert!(listed[0]
1790            .first_message
1791            .as_deref()
1792            .is_some_and(|summary| summary.contains("first")));
1793        assert!(Session::resume(&home, "missing").is_err());
1794
1795        let file = fs::read_to_string(resumed.path).expect("session file");
1796        assert!(file.lines().count() >= 3);
1797        assert!(!file.contains("TEST_KEY_VALUE"));
1798        fs::remove_dir_all(home).expect("remove temp home");
1799    }
1800
1801    #[test]
1802    fn compaction_appends_a_boundary_and_reconstructs_only_retained_messages() {
1803        let home = temporary_home();
1804        let cwd = std::env::current_dir().expect("cwd");
1805        let llm = LlmSettings {
1806            base_url: "http://localhost".to_owned(),
1807            model: "model".to_owned(),
1808            api_key_env: "LUCY_COMPACTION_KEY".to_owned(),
1809            effort: None,
1810        };
1811        let mut session =
1812            Session::create_with_secret(&home, &cwd, "stable prompt".to_owned(), llm, None)
1813                .expect("create");
1814        session
1815            .append_message(ChatMessage::user("old request".to_owned()))
1816            .expect("old user");
1817        session
1818            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1819            .expect("old assistant");
1820        session
1821            .append_message(ChatMessage::user("recent request".to_owned()))
1822            .expect("recent user");
1823        session
1824            .append_message(ChatMessage::assistant(
1825                "recent answer".to_owned(),
1826                Vec::new(),
1827            ))
1828            .expect("recent assistant");
1829
1830        session
1831            .append_compaction("old work summary".to_owned(), 2, 123)
1832            .expect("append compaction");
1833
1834        let provider_messages = session.provider_messages();
1835        assert_eq!(provider_messages[0].role, "system");
1836        assert_eq!(provider_messages[1].role, "user");
1837        assert!(provider_messages[1]
1838            .content
1839            .as_deref()
1840            .is_some_and(|content| content.contains("old work summary")));
1841        let provider_text = provider_messages
1842            .iter()
1843            .filter_map(|message| message.content.as_deref())
1844            .collect::<Vec<_>>()
1845            .join("\n");
1846        assert!(!provider_text.contains("old request"));
1847        assert!(!provider_text.contains("old answer"));
1848        assert!(provider_text.contains("recent request"));
1849        assert!(provider_text.contains("recent answer"));
1850        assert!(matches!(
1851            session.history.last(),
1852            Some(SessionHistoryRecord::Compaction(CompactionRecord {
1853                first_kept_message: 2,
1854                tokens_before: 123,
1855                ..
1856            }))
1857        ));
1858
1859        let resumed = Session::resume(&home, &session.id).expect("resume");
1860        assert_eq!(resumed.provider_messages(), provider_messages);
1861        assert_eq!(resumed.messages.len(), 4, "history remains append-only");
1862        fs::remove_dir_all(home).expect("cleanup");
1863    }
1864
1865    #[test]
1866    fn compaction_rejects_a_secret_in_the_summary_without_appending() {
1867        let home = temporary_home();
1868        let cwd = std::env::current_dir().expect("cwd");
1869        let key_env = format!("LUCY_COMPACTION_SECRET_{}", std::process::id());
1870        let secret = "provider-secret";
1871        std::env::set_var(&key_env, secret);
1872        let llm = LlmSettings {
1873            base_url: "http://localhost".to_owned(),
1874            model: "model".to_owned(),
1875            api_key_env: key_env.clone(),
1876            effort: None,
1877        };
1878        let mut session =
1879            Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, Some(secret))
1880                .expect("create");
1881        session
1882            .append_message(ChatMessage::user("one".to_owned()))
1883            .expect("user");
1884        let before = fs::read_to_string(&session.path).expect("session bytes");
1885
1886        let error = session
1887            .append_compaction(secret.to_owned(), 0, 1)
1888            .expect_err("secret summary should be rejected");
1889        assert!(error.to_string().contains("session record rejected"));
1890        assert_eq!(
1891            fs::read_to_string(&session.path).expect("session bytes"),
1892            before
1893        );
1894        assert!(!session
1895            .history
1896            .iter()
1897            .any(|record| matches!(record, SessionHistoryRecord::Compaction(_))));
1898
1899        std::env::remove_var(key_env);
1900        fs::remove_dir_all(home).expect("cleanup");
1901    }
1902
1903    #[test]
1904    fn background_results_persist_and_materialize_once_at_delivery_position() {
1905        let home = temporary_home();
1906        let cwd = std::env::current_dir().expect("cwd");
1907        let llm = LlmSettings {
1908            base_url: "http://localhost".to_owned(),
1909            model: "model".to_owned(),
1910            api_key_env: "LUCY_BACKGROUND_RESULT_KEY".to_owned(),
1911            effort: None,
1912        };
1913        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1914            .expect("create");
1915        session
1916            .append_message(ChatMessage::user("original request".to_owned()))
1917            .expect("user");
1918        let pending = BackgroundResultPending {
1919            timestamp: 0,
1920            completion_id: "completion-1".to_owned(),
1921            task_id: "subagent-1".to_owned(),
1922            child_session_id: "child-1".to_owned(),
1923            task: "inspect".to_owned(),
1924            status: ChildSessionStatus::Completed,
1925            result: serde_json::json!({"output":"done"}),
1926            completed_at: 10,
1927        };
1928        assert!(session
1929            .append_background_result_pending(pending.clone())
1930            .expect("pending"));
1931        let undelivered = session.undelivered_background_results();
1932        assert_eq!(undelivered.len(), 1);
1933        assert_eq!(undelivered[0].completion_id, pending.completion_id);
1934        assert_eq!(undelivered[0].result, pending.result);
1935        let mut collision = pending.clone();
1936        collision.child_session_id = "different-child".to_owned();
1937        assert!(session
1938            .append_background_result_pending(collision)
1939            .expect_err("identity collision rejected")
1940            .to_string()
1941            .contains("identity collision"));
1942        assert!(session
1943            .append_background_result_delivered(
1944                "completion-1",
1945                "turn-1".to_owned(),
1946                BackgroundResultDelivery::Synthetic,
1947            )
1948            .expect("delivered"));
1949        assert!(!session
1950            .append_background_result_delivered(
1951                "completion-1",
1952                "turn-1".to_owned(),
1953                BackgroundResultDelivery::Synthetic,
1954            )
1955            .expect("duplicate is idempotent"));
1956        session
1957            .append_message(ChatMessage::user("later request".to_owned()))
1958            .expect("later user");
1959
1960        let messages = session.provider_messages();
1961        let synthetic = messages
1962            .iter()
1963            .position(|message| {
1964                message.role == "assistant"
1965                    && message
1966                        .tool_calls
1967                        .first()
1968                        .is_some_and(|call| call.name == BACKGROUND_RESULT_TOOL_NAME)
1969            })
1970            .expect("synthetic call");
1971        assert_eq!(messages[synthetic + 1].role, "tool");
1972        assert_eq!(
1973            messages[synthetic + 1].name.as_deref(),
1974            Some(BACKGROUND_RESULT_TOOL_NAME)
1975        );
1976        assert_eq!(
1977            messages[synthetic + 2].content.as_deref(),
1978            Some("later request")
1979        );
1980        assert!(session.undelivered_background_results().is_empty());
1981
1982        let resumed = Session::resume(&home, &session.id).expect("resume");
1983        assert_eq!(resumed.provider_messages(), messages);
1984        assert!(resumed.undelivered_background_results().is_empty());
1985        fs::remove_dir_all(home).expect("cleanup");
1986    }
1987
1988    #[test]
1989    fn background_result_rejects_a_secret_without_appending() {
1990        let home = temporary_home();
1991        let cwd = std::env::current_dir().expect("cwd");
1992        let secret = "provider-secret";
1993        let llm = LlmSettings {
1994            base_url: "http://localhost".to_owned(),
1995            model: "model".to_owned(),
1996            api_key_env: "LUCY_BACKGROUND_RESULT_SECRET".to_owned(),
1997            effort: None,
1998        };
1999        let mut session =
2000            Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, Some(secret))
2001                .expect("create");
2002        let before = fs::read_to_string(&session.path).expect("before");
2003        let error = session
2004            .append_background_result_pending(BackgroundResultPending {
2005                timestamp: 0,
2006                completion_id: "completion-1".to_owned(),
2007                task_id: "subagent-1".to_owned(),
2008                child_session_id: "child-1".to_owned(),
2009                task: "inspect".to_owned(),
2010                status: ChildSessionStatus::Completed,
2011                result: serde_json::json!({"output":secret}),
2012                completed_at: 10,
2013            })
2014            .expect_err("secret result rejected");
2015        assert!(error.to_string().contains("session record rejected"));
2016        assert_eq!(fs::read_to_string(&session.path).expect("after"), before);
2017        assert!(session.undelivered_background_results().is_empty());
2018        fs::remove_dir_all(home).expect("cleanup");
2019    }
2020
2021    #[test]
2022    fn reasoning_details_round_trip_through_session_and_provider_history() {
2023        let home = temporary_home();
2024        let cwd = std::env::current_dir().expect("cwd");
2025        let llm = LlmSettings {
2026            base_url: "http://localhost".to_owned(),
2027            model: "model".to_owned(),
2028            api_key_env: "LUCY_REASONING_DETAILS_KEY".to_owned(),
2029            effort: None,
2030        };
2031        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
2032            .expect("create");
2033        let details = vec![serde_json::json!({
2034            "type": "reasoning.text",
2035            "text": "provider detail"
2036        })];
2037        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
2038        assistant.reasoning_details = Some(details.clone());
2039        session.append_message(assistant).expect("assistant");
2040
2041        let resumed = Session::resume(&home, &session.id).expect("resume");
2042        assert_eq!(resumed.messages[0].reasoning_details, Some(details.clone()));
2043        let provider_assistant = resumed
2044            .provider_messages()
2045            .into_iter()
2046            .find(|message| message.role == "assistant")
2047            .expect("provider assistant");
2048        assert_eq!(provider_assistant.reasoning_details, Some(details));
2049        fs::remove_dir_all(home).expect("remove temp home");
2050    }
2051
2052    #[test]
2053    fn append_rejects_secrets_nested_in_reasoning_details() {
2054        let home = temporary_home();
2055        let cwd = std::env::current_dir().expect("cwd");
2056        let llm = LlmSettings {
2057            base_url: "http://localhost".to_owned(),
2058            model: "model".to_owned(),
2059            api_key_env: "LUCY_REASONING_SECRET_KEY".to_owned(),
2060            effort: None,
2061        };
2062        let mut session = Session::create_with_secret(
2063            &home,
2064            &cwd,
2065            "prompt".to_owned(),
2066            llm,
2067            Some("provider-secret"),
2068        )
2069        .expect("create");
2070        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
2071        assistant.reasoning_details = Some(vec![serde_json::json!({
2072            "type": "reasoning.text",
2073            "text": "provider-secret"
2074        })]);
2075        let error = session
2076            .append_message(assistant)
2077            .expect_err("secret reasoning details");
2078        assert_eq!(error.to_string(), "session record rejected");
2079        fs::remove_dir_all(home).expect("remove temp home");
2080    }
2081
2082    #[test]
2083    fn child_session_persists_parent_link_transcript_and_terminal_status() {
2084        let home = temporary_home();
2085        let cwd = std::env::current_dir().expect("cwd");
2086        let key_env = format!("LUCY_CHILD_SESSION_KEY_{}", std::process::id());
2087        std::env::set_var(&key_env, "provider-secret");
2088        let llm = LlmSettings {
2089            base_url: "http://localhost".to_owned(),
2090            model: "model".to_owned(),
2091            api_key_env: key_env.clone(),
2092            effort: Some("medium".to_owned()),
2093        };
2094        let mut child = ChildSession::create(
2095            &home,
2096            "parent-session",
2097            &cwd,
2098            "boot context".to_owned(),
2099            llm,
2100            "inspect the worker".to_owned(),
2101            Some("provider-secret"),
2102        )
2103        .expect("child session");
2104        child
2105            .append_message(ChatMessage::user("inspect the worker".to_owned()))
2106            .expect("task message");
2107        child
2108            .append_message(ChatMessage::assistant("done".to_owned(), Vec::new()))
2109            .expect("assistant message");
2110        child
2111            .append_status(
2112                ChildSessionStatus::Completed,
2113                None,
2114                Some(serde_json::json!({"output":"done"})),
2115            )
2116            .expect("status");
2117
2118        let raw = fs::read_to_string(&child.path).expect("child JSONL");
2119        assert!(raw.contains("\"record\":\"subagent_session\""));
2120        assert!(raw.contains("\"parent_session_id\":\"parent-session\""));
2121        assert!(raw.contains("\"session_kind\":\"subagent\""));
2122        assert!(raw.contains("\"status\":\"completed\""));
2123        assert!(!raw.contains("provider-secret"));
2124        assert_eq!(child.provider_messages().len(), 3);
2125        assert_eq!(child.status, ChildSessionStatus::Completed);
2126
2127        std::env::remove_var(key_env);
2128        fs::remove_dir_all(home).expect("remove temp home");
2129    }
2130
2131    #[test]
2132    fn interruption_records_are_valid_and_resume_in_file_order_without_provider_fragments() {
2133        let home = temporary_home();
2134        let cwd = std::env::current_dir().expect("cwd");
2135        let llm = LlmSettings {
2136            base_url: "http://localhost".to_owned(),
2137            model: "model".to_owned(),
2138            api_key_env: "LUCY_NO_SESSION_KEY".to_owned(),
2139            effort: None,
2140        };
2141        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
2142            .expect("create");
2143        session
2144            .append_message(ChatMessage::user("hello".to_owned()))
2145            .expect("user");
2146        session
2147            .append_interruption(InterruptionRecord {
2148                timestamp: 0,
2149                reason: "user_cancelled".to_owned(),
2150                phase: "provider_stream".to_owned(),
2151                assistant_text: "partial answer".to_owned(),
2152                tool_calls: vec![ChatToolCall {
2153                    id: "partial-call".to_owned(),
2154                    name: "cmd".to_owned(),
2155                    arguments: "{\"command\":".to_owned(),
2156                }],
2157                tool_results: Vec::new(),
2158            })
2159            .expect("interruption");
2160
2161        session
2162            .append_message(ChatMessage::assistant(
2163                String::new(),
2164                vec![ChatToolCall {
2165                    id: "call-1".to_owned(),
2166                    name: "cmd".to_owned(),
2167                    arguments: r#"{"command":"sleep 1"}"#.to_owned(),
2168                }],
2169            ))
2170            .expect("assistant tool call");
2171        session
2172            .append_interruption(InterruptionRecord {
2173                timestamp: 0,
2174                reason: "user_cancelled".to_owned(),
2175                phase: "cmd".to_owned(),
2176                assistant_text: String::new(),
2177                tool_calls: Vec::new(),
2178                tool_results: vec![SessionToolResult {
2179                    id: "call-1".to_owned(),
2180                    name: "cmd".to_owned(),
2181                    result: serde_json::json!({"canceled": true}),
2182                }],
2183            })
2184            .expect("command interruption");
2185
2186        let raw = fs::read_to_string(&session.path).expect("session JSONL");
2187        for line in raw.lines() {
2188            serde_json::from_str::<Value>(line).expect("valid JSONL record");
2189        }
2190        let resumed = Session::resume(&home, &session.id).expect("resume");
2191        assert_eq!(resumed.history.len(), 4);
2192        assert!(matches!(
2193            resumed.history[0],
2194            SessionHistoryRecord::Message { .. }
2195        ));
2196        assert!(matches!(
2197            resumed.history[1],
2198            SessionHistoryRecord::Interruption { .. }
2199        ));
2200        assert_eq!(resumed.messages.len(), 2);
2201        let provider_messages = resumed.provider_messages();
2202        assert_eq!(provider_messages.len(), 4);
2203        assert!(provider_messages.iter().any(|message| {
2204            message.role == "tool" && message.tool_call_id.as_deref() == Some("call-1")
2205        }));
2206        assert!(!resumed.provider_messages().iter().any(|message| {
2207            message
2208                .tool_calls
2209                .iter()
2210                .any(|call| call.id == "partial-call")
2211        }));
2212        fs::remove_dir_all(home).expect("remove temp home");
2213    }
2214}