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        validate_session_id(id)?;
485        let directory = sessions_dir(home);
486        let lucy_directory = lucy_dir(home);
487        ensure_not_symlink(&lucy_directory)?;
488        if lucy_directory.is_dir() {
489            ensure_private_dir(&lucy_directory)?;
490        }
491        ensure_not_symlink(&directory)?;
492        if directory.is_dir() {
493            ensure_private_dir(&directory)?;
494        }
495        let path = directory.join(format!("{id}.jsonl"));
496        ensure_not_symlink(&path)?;
497        if !path.is_file() {
498            return Err(SessionError::new("session not found"));
499        }
500
501        ensure_private_file(&path)?;
502        let raw =
503            fs::read(&path).map_err(|_error| SessionError::new("unable to read session file"))?;
504        let active_secret = session_header_secret(&raw);
505        if let Some(secret) = active_secret.as_deref() {
506            if conflicts_with_protected_literal(secret) || bytes_contain_secret(&raw, secret) {
507                return Err(session_header_rejected(secret));
508            }
509        }
510
511        let reader = BufReader::new(raw.as_slice());
512        let mut header = None;
513        let mut messages = Vec::new();
514        let mut history = Vec::new();
515        let mut updated_at = None;
516
517        for (line_number, line) in reader.lines().enumerate() {
518            let line = line.map_err(|_error| {
519                session_error("unable to read session file", active_secret.as_deref())
520            })?;
521            if line.trim().is_empty() {
522                continue;
523            }
524            let value = parse_json_value(&line).map_err(|_error| {
525                session_error(
526                    format!("invalid session record at line {}", line_number + 1),
527                    active_secret.as_deref(),
528                )
529            })?;
530            if let Some(secret) = active_secret.as_deref() {
531                if json_value_contains_secret(&value, secret) {
532                    return Err(session_header_rejected(secret));
533                }
534            }
535            let record: SessionRecord = serde_json::from_value(value).map_err(|_error| {
536                session_error(
537                    format!("invalid session record at line {}", line_number + 1),
538                    active_secret.as_deref(),
539                )
540            })?;
541            if let Some(secret) = active_secret.as_deref() {
542                if record_contains_secret(&record, secret) {
543                    return Err(session_header_rejected(secret));
544                }
545            }
546            match record {
547                SessionRecord::Session {
548                    version,
549                    session_id,
550                    created_at,
551                    cwd,
552                    boot_system_prompt,
553                    llm,
554                    skills,
555                } => {
556                    if version != 1 || session_id != id || header.is_some() {
557                        return Err(session_error(
558                            "invalid session header",
559                            active_secret.as_deref(),
560                        ));
561                    }
562                    header = Some((created_at, cwd, boot_system_prompt, llm, skills));
563                }
564                SessionRecord::ProviderSettings {
565                    timestamp,
566                    model,
567                    effort,
568                } => {
569                    if header.is_none() {
570                        return Err(session_error(
571                            "session provider settings precede header",
572                            active_secret.as_deref(),
573                        ));
574                    }
575                    updated_at = Some(timestamp);
576                    history.push(SessionHistoryRecord::ProviderSettings {
577                        timestamp,
578                        model,
579                        effort,
580                    });
581                }
582                SessionRecord::Message { timestamp, message } => {
583                    if header.is_none() {
584                        return Err(session_error(
585                            "session message precedes header",
586                            active_secret.as_deref(),
587                        ));
588                    }
589                    updated_at = Some(timestamp);
590                    history.push(SessionHistoryRecord::Message {
591                        timestamp,
592                        message: message.clone(),
593                    });
594                    messages.push(message);
595                }
596                SessionRecord::Interruption {
597                    timestamp,
598                    reason,
599                    phase,
600                    assistant_text,
601                    tool_calls,
602                    tool_results,
603                } => {
604                    if header.is_none() {
605                        return Err(session_error(
606                            "session interruption precedes header",
607                            active_secret.as_deref(),
608                        ));
609                    }
610                    updated_at = Some(timestamp);
611                    history.push(SessionHistoryRecord::Interruption {
612                        timestamp,
613                        reason,
614                        phase,
615                        assistant_text,
616                        tool_calls,
617                        tool_results,
618                    });
619                }
620                SessionRecord::Compaction {
621                    timestamp,
622                    summary,
623                    first_kept_message,
624                    tokens_before,
625                } => {
626                    if header.is_none() {
627                        return Err(session_error(
628                            "session compaction precedes header",
629                            active_secret.as_deref(),
630                        ));
631                    }
632                    updated_at = Some(timestamp);
633                    history.push(SessionHistoryRecord::Compaction(CompactionRecord {
634                        timestamp,
635                        summary,
636                        first_kept_message,
637                        tokens_before,
638                    }));
639                }
640                SessionRecord::BackgroundResultPending(pending) => {
641                    if header.is_none() {
642                        return Err(session_error(
643                            "background result precedes header",
644                            active_secret.as_deref(),
645                        ));
646                    }
647                    if history.iter().any(|record| {
648                        matches!(
649                            record,
650                            SessionHistoryRecord::BackgroundResultPending(existing)
651                                if existing.completion_id == pending.completion_id
652                        )
653                    }) {
654                        return Err(session_error(
655                            "duplicate pending background result",
656                            active_secret.as_deref(),
657                        ));
658                    }
659                    updated_at = Some(pending.timestamp);
660                    history.push(SessionHistoryRecord::BackgroundResultPending(pending));
661                }
662                SessionRecord::BackgroundResultDelivered(delivered) => {
663                    if header.is_none()
664                        || !history.iter().any(|record| {
665                            matches!(
666                                record,
667                                SessionHistoryRecord::BackgroundResultPending(pending)
668                                    if pending.completion_id == delivered.completion_id
669                            )
670                        })
671                        || history.iter().any(|record| {
672                            matches!(
673                                record,
674                                SessionHistoryRecord::BackgroundResultDelivered(existing)
675                                    if existing.completion_id == delivered.completion_id
676                            )
677                        })
678                    {
679                        return Err(session_error(
680                            "invalid delivered background result",
681                            active_secret.as_deref(),
682                        ));
683                    }
684                    updated_at = Some(delivered.timestamp);
685                    history.push(SessionHistoryRecord::BackgroundResultDelivered(delivered));
686                }
687            }
688        }
689
690        let message_count = messages.len();
691        if history.iter().any(|record| {
692            matches!(
693                record,
694                SessionHistoryRecord::Compaction(compaction)
695                    if compaction.first_kept_message > message_count
696            )
697        }) {
698            return Err(session_error(
699                "invalid compaction boundary",
700                active_secret.as_deref(),
701            ));
702        }
703
704        let Some((created_at, cwd, boot_system_prompt, llm, skills)) = header else {
705            return Err(session_error(
706                "session has no header",
707                active_secret.as_deref(),
708            ));
709        };
710        let cwd = PathBuf::from(cwd);
711        Ok(Self {
712            id: id.to_owned(),
713            path,
714            cwd,
715            boot_system_prompt,
716            llm,
717            skills,
718            created_at,
719            updated_at: updated_at.unwrap_or(created_at),
720            messages,
721            history,
722            secret: active_secret,
723        })
724    }
725
726    pub fn append_provider_settings(
727        &mut self,
728        model: String,
729        effort: Option<String>,
730    ) -> Result<(), SessionError> {
731        let timestamp = now();
732        let record = SessionRecord::ProviderSettings {
733            timestamp,
734            model: model.clone(),
735            effort: effort.clone(),
736        };
737        if let Some(secret) = self.secret.as_deref() {
738            if record_contains_secret(&record, secret) {
739                return Err(session_record_rejected(secret));
740            }
741        }
742        let mut file = open_session_for_append(&self.path)?;
743        write_record(&mut file, &record)?;
744        self.history.push(SessionHistoryRecord::ProviderSettings {
745            timestamp,
746            model,
747            effort,
748        });
749        self.updated_at = timestamp;
750        Ok(())
751    }
752
753    pub fn append_message(&mut self, message: ChatMessage) -> Result<(), SessionError> {
754        let timestamp = now();
755        let record = SessionRecord::Message {
756            timestamp,
757            message: message.clone(),
758        };
759        if let Some(secret) = self.secret.as_deref() {
760            if record_contains_secret(&record, secret) {
761                return Err(session_record_rejected(secret));
762            }
763        }
764        let mut file = open_session_for_append(&self.path)?;
765        write_record(&mut file, &record)?;
766        self.messages.push(message.clone());
767        self.history
768            .push(SessionHistoryRecord::Message { timestamp, message });
769        self.updated_at = timestamp;
770        Ok(())
771    }
772
773    pub fn append_interruption(
774        &mut self,
775        mut interruption: InterruptionRecord,
776    ) -> Result<(), SessionError> {
777        let timestamp = now();
778        interruption.timestamp = timestamp;
779        let record = SessionRecord::Interruption {
780            timestamp,
781            reason: interruption.reason.clone(),
782            phase: interruption.phase.clone(),
783            assistant_text: interruption.assistant_text.clone(),
784            tool_calls: interruption.tool_calls.clone(),
785            tool_results: interruption.tool_results.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.history.push(SessionHistoryRecord::Interruption {
795            timestamp,
796            reason: interruption.reason,
797            phase: interruption.phase,
798            assistant_text: interruption.assistant_text,
799            tool_calls: interruption.tool_calls,
800            tool_results: interruption.tool_results,
801        });
802        self.updated_at = timestamp;
803        Ok(())
804    }
805
806    pub fn append_background_result_pending(
807        &mut self,
808        mut pending: BackgroundResultPending,
809    ) -> Result<bool, SessionError> {
810        if let Some(existing) = self.history.iter().find_map(|record| match record {
811            SessionHistoryRecord::BackgroundResultPending(existing)
812                if existing.completion_id == pending.completion_id =>
813            {
814                Some(existing)
815            }
816            _ => None,
817        }) {
818            let same_completion = existing.task_id == pending.task_id
819                && existing.child_session_id == pending.child_session_id
820                && existing.task == pending.task
821                && existing.status == pending.status
822                && existing.result == pending.result
823                && existing.completed_at == pending.completed_at;
824            return if same_completion {
825                Ok(false)
826            } else {
827                Err(SessionError::new("background result identity collision"))
828            };
829        }
830        pending.timestamp = now();
831        let record = SessionRecord::BackgroundResultPending(pending.clone());
832        if self
833            .secret
834            .as_deref()
835            .is_some_and(|secret| record_contains_secret(&record, secret))
836        {
837            return Err(session_record_rejected(
838                self.secret.as_deref().unwrap_or_default(),
839            ));
840        }
841        let mut file = open_session_for_append(&self.path)?;
842        write_record(&mut file, &record)?;
843        self.updated_at = pending.timestamp;
844        self.history
845            .push(SessionHistoryRecord::BackgroundResultPending(pending));
846        Ok(true)
847    }
848
849    pub fn append_background_result_delivered(
850        &mut self,
851        completion_id: &str,
852        logical_turn_id: String,
853        delivery: BackgroundResultDelivery,
854    ) -> Result<bool, SessionError> {
855        let has_pending = self.history.iter().any(|record| {
856            matches!(
857                record,
858                SessionHistoryRecord::BackgroundResultPending(pending)
859                    if pending.completion_id == completion_id
860            )
861        });
862        if !has_pending {
863            return Err(SessionError::new("background result has no pending record"));
864        }
865        if self.history.iter().any(|record| {
866            matches!(
867                record,
868                SessionHistoryRecord::BackgroundResultDelivered(existing)
869                    if existing.completion_id == completion_id
870            )
871        }) {
872            return Ok(false);
873        }
874        let delivered = BackgroundResultDelivered {
875            timestamp: now(),
876            completion_id: completion_id.to_owned(),
877            logical_turn_id,
878            delivery,
879        };
880        let record = SessionRecord::BackgroundResultDelivered(delivered.clone());
881        if self
882            .secret
883            .as_deref()
884            .is_some_and(|secret| record_contains_secret(&record, secret))
885        {
886            return Err(session_record_rejected(
887                self.secret.as_deref().unwrap_or_default(),
888            ));
889        }
890        let mut file = open_session_for_append(&self.path)?;
891        write_record(&mut file, &record)?;
892        self.updated_at = delivered.timestamp;
893        self.history
894            .push(SessionHistoryRecord::BackgroundResultDelivered(delivered));
895        Ok(true)
896    }
897
898    pub fn undelivered_background_results(&self) -> Vec<BackgroundResultPending> {
899        let delivered = self
900            .history
901            .iter()
902            .filter_map(|record| match record {
903                SessionHistoryRecord::BackgroundResultDelivered(delivered) => {
904                    Some(delivered.completion_id.as_str())
905                }
906                _ => None,
907            })
908            .collect::<HashSet<_>>();
909        self.history
910            .iter()
911            .filter_map(|record| match record {
912                SessionHistoryRecord::BackgroundResultPending(pending)
913                    if !delivered.contains(pending.completion_id.as_str()) =>
914                {
915                    Some(pending.clone())
916                }
917                _ => None,
918            })
919            .collect()
920    }
921
922    /// Append a summary boundary without deleting the historical records that
923    /// preceded it. `first_kept_message` counts ordinary message records from
924    /// the start of the session, excluding the boot system prompt.
925    pub fn append_compaction(
926        &mut self,
927        summary: String,
928        first_kept_message: usize,
929        tokens_before: usize,
930    ) -> Result<(), SessionError> {
931        let timestamp = now();
932        let record = SessionRecord::Compaction {
933            timestamp,
934            summary: summary.clone(),
935            first_kept_message,
936            tokens_before,
937        };
938        if let Some(secret) = self.secret.as_deref() {
939            if record_contains_secret(&record, secret) {
940                return Err(session_record_rejected(secret));
941            }
942        }
943        let mut file = open_session_for_append(&self.path)?;
944        write_record(&mut file, &record)?;
945        self.history
946            .push(SessionHistoryRecord::Compaction(CompactionRecord {
947                timestamp,
948                summary,
949                first_kept_message,
950                tokens_before,
951            }));
952        self.updated_at = timestamp;
953        Ok(())
954    }
955
956    pub fn provider_messages(&self) -> Vec<ChatMessage> {
957        let latest_compaction = self.history.iter().rev().find_map(|record| match record {
958            SessionHistoryRecord::Compaction(compaction) => Some(compaction),
959            _ => None,
960        });
961        let first_kept_message = latest_compaction.map(|compaction| compaction.first_kept_message);
962        let interruption_results = self
963            .history
964            .iter()
965            .filter_map(|record| match record {
966                SessionHistoryRecord::Interruption {
967                    phase,
968                    tool_results,
969                    ..
970                } if phase == "cmd" => Some(tool_results),
971                _ => None,
972            })
973            .flatten()
974            .count();
975        let mut messages = Vec::with_capacity(
976            self.messages.len()
977                + 1
978                + interruption_results
979                + usize::from(latest_compaction.is_some()),
980        );
981        let mut declared_tool_calls = HashSet::new();
982        let mut completed_tool_calls = HashSet::new();
983        let mut pending_background_results = std::collections::HashMap::new();
984        messages.push(ChatMessage::system(self.boot_system_prompt.clone()));
985        if let Some(compaction) = latest_compaction {
986            messages.push(compaction_summary_message(&compaction.summary));
987        }
988
989        let mut message_ordinal = 0usize;
990        for record in &self.history {
991            match record {
992                SessionHistoryRecord::Message { message, .. } => {
993                    let include =
994                        first_kept_message.is_none_or(|boundary| message_ordinal >= boundary);
995                    message_ordinal += 1;
996                    if !include {
997                        continue;
998                    }
999                    if message.role == "assistant" {
1000                        declared_tool_calls
1001                            .extend(message.tool_calls.iter().map(|call| call.id.clone()));
1002                    }
1003                    if message.role == "tool" {
1004                        if let Some(id) = message.tool_call_id.as_deref() {
1005                            completed_tool_calls.insert(id.to_owned());
1006                        }
1007                    }
1008                    messages.push(message.clone());
1009                }
1010                SessionHistoryRecord::Interruption {
1011                    phase,
1012                    tool_results,
1013                    ..
1014                } if phase == "cmd" => {
1015                    for observation in tool_results {
1016                        if !declared_tool_calls.contains(&observation.id)
1017                            || completed_tool_calls.contains(&observation.id)
1018                        {
1019                            continue;
1020                        }
1021                        let Ok(content) = serde_json::to_string(&observation.result) else {
1022                            continue;
1023                        };
1024                        messages.push(ChatMessage::tool(
1025                            observation.id.clone(),
1026                            observation.name.clone(),
1027                            content,
1028                        ));
1029                        completed_tool_calls.insert(observation.id.clone());
1030                    }
1031                }
1032                SessionHistoryRecord::BackgroundResultPending(pending) => {
1033                    pending_background_results
1034                        .insert(pending.completion_id.clone(), pending.clone());
1035                }
1036                SessionHistoryRecord::BackgroundResultDelivered(delivered)
1037                    if delivered.delivery == BackgroundResultDelivery::Synthetic
1038                        && first_kept_message
1039                            .is_none_or(|boundary| message_ordinal >= boundary) =>
1040                {
1041                    if let Some(pending) = pending_background_results.get(&delivered.completion_id)
1042                    {
1043                        messages.extend(background_result_messages(pending));
1044                    }
1045                }
1046                SessionHistoryRecord::ProviderSettings { .. }
1047                | SessionHistoryRecord::Interruption { .. }
1048                | SessionHistoryRecord::Compaction(_)
1049                | SessionHistoryRecord::BackgroundResultDelivered(_) => {}
1050            }
1051        }
1052        messages
1053    }
1054
1055    pub fn list(home: &Path) -> Result<Vec<SessionMetadata>, SessionError> {
1056        let directory = sessions_dir(home);
1057        let lucy_directory = lucy_dir(home);
1058        ensure_not_symlink(&lucy_directory)?;
1059        if lucy_directory.is_dir() {
1060            ensure_private_dir(&lucy_directory)?;
1061        }
1062        ensure_not_symlink(&directory)?;
1063        if directory.is_dir() {
1064            ensure_private_dir(&directory)?;
1065        }
1066        let entries = match fs::read_dir(&directory) {
1067            Ok(entries) => entries,
1068            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1069            Err(_error) => return Err(SessionError::new("unable to list sessions")),
1070        };
1071
1072        let mut paths = Vec::new();
1073        for entry in entries {
1074            let entry = entry?;
1075            let path = entry.path();
1076            let metadata = match fs::symlink_metadata(&path) {
1077                Ok(metadata) => metadata,
1078                Err(_) => continue,
1079            };
1080            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
1081                && metadata.is_file()
1082            {
1083                paths.push(path);
1084            }
1085        }
1086        paths.sort();
1087
1088        let mut metadata = Vec::new();
1089        for path in paths {
1090            let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
1091                continue;
1092            };
1093            let Ok(session) = Self::resume(home, id) else {
1094                continue;
1095            };
1096            metadata.push(SessionMetadata {
1097                record_type: "session_metadata",
1098                session_id: session.id,
1099                created_at: session.created_at,
1100                updated_at: session.updated_at,
1101                first_message: session.messages.first().map(safe_message_summary),
1102                last_message: session.messages.last().map(safe_message_summary),
1103            });
1104        }
1105        Ok(metadata)
1106    }
1107}
1108
1109struct DuplicateKeyValue(Value);
1110
1111impl<'de> Deserialize<'de> for DuplicateKeyValue {
1112    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1113    where
1114        D: Deserializer<'de>,
1115    {
1116        deserializer
1117            .deserialize_any(DuplicateKeyValueVisitor)
1118            .map(Self)
1119    }
1120}
1121
1122struct DuplicateKeyValueSeed;
1123
1124impl<'de> DeserializeSeed<'de> for DuplicateKeyValueSeed {
1125    type Value = Value;
1126
1127    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1128    where
1129        D: Deserializer<'de>,
1130    {
1131        deserializer.deserialize_any(DuplicateKeyValueVisitor)
1132    }
1133}
1134
1135struct DuplicateKeyValueVisitor;
1136
1137impl<'de> Visitor<'de> for DuplicateKeyValueVisitor {
1138    type Value = Value;
1139
1140    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1141        formatter.write_str("a valid JSON value")
1142    }
1143
1144    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
1145        Ok(Value::Bool(value))
1146    }
1147
1148    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1149        Ok(Value::Number(value.into()))
1150    }
1151
1152    fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
1153    where
1154        E: de::Error,
1155    {
1156        serde_json::Number::from_i128(value)
1157            .map(Value::Number)
1158            .ok_or_else(|| de::Error::custom("JSON number out of range"))
1159    }
1160
1161    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1162        Ok(Value::Number(value.into()))
1163    }
1164
1165    fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
1166    where
1167        E: de::Error,
1168    {
1169        serde_json::Number::from_u128(value)
1170            .map(Value::Number)
1171            .ok_or_else(|| de::Error::custom("JSON number out of range"))
1172    }
1173
1174    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1175    where
1176        E: de::Error,
1177    {
1178        Ok(serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number))
1179    }
1180
1181    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
1182        Ok(Value::String(value.to_owned()))
1183    }
1184
1185    fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
1186        Ok(Value::String(value))
1187    }
1188
1189    fn visit_none<E>(self) -> Result<Self::Value, E> {
1190        Ok(Value::Null)
1191    }
1192
1193    fn visit_unit<E>(self) -> Result<Self::Value, E> {
1194        Ok(Value::Null)
1195    }
1196
1197    fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
1198    where
1199        A: SeqAccess<'de>,
1200    {
1201        let mut values = Vec::new();
1202        while let Some(value) = access.next_element_seed(DuplicateKeyValueSeed)? {
1203            values.push(value);
1204        }
1205        Ok(Value::Array(values))
1206    }
1207
1208    fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
1209    where
1210        A: MapAccess<'de>,
1211    {
1212        let mut values = serde_json::Map::new();
1213        while let Some(key) = access.next_key::<String>()? {
1214            if values.contains_key(&key) {
1215                return Err(de::Error::custom("duplicate object key"));
1216            }
1217            let value = access.next_value_seed(DuplicateKeyValueSeed)?;
1218            values.insert(key, value);
1219        }
1220        Ok(Value::Object(values))
1221    }
1222}
1223
1224fn parse_json_value(line: &str) -> Result<Value, serde_json::Error> {
1225    serde_json::from_str::<DuplicateKeyValue>(line).map(|value| value.0)
1226}
1227
1228fn session_header_secret(raw: &[u8]) -> Option<String> {
1229    let line = raw
1230        .split(|byte| *byte == b'\n')
1231        .find(|line| !line.iter().all(|byte| byte.is_ascii_whitespace()))?;
1232    let value = parse_json_value(std::str::from_utf8(line).ok()?).ok()?;
1233    let api_key_env = value
1234        .get("llm")
1235        .and_then(|llm| llm.get("api_key_env"))
1236        .and_then(Value::as_str)?;
1237    let secret = std::env::var(api_key_env).ok()?;
1238    (!secret.is_empty()).then_some(secret)
1239}
1240
1241fn bytes_contain_secret(raw: &[u8], secret: &str) -> bool {
1242    let secret = secret.as_bytes();
1243    !secret.is_empty() && raw.windows(secret.len()).any(|window| window == secret)
1244}
1245
1246fn record_contains_secret(record: &SessionRecord, secret: &str) -> bool {
1247    if secret.is_empty() {
1248        return false;
1249    }
1250    if serde_json::to_vec(record)
1251        .ok()
1252        .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
1253    {
1254        return true;
1255    }
1256    match record {
1257        SessionRecord::Session {
1258            version,
1259            session_id,
1260            created_at,
1261            cwd,
1262            boot_system_prompt,
1263            llm,
1264            skills,
1265        } => {
1266            version.to_string().contains(secret)
1267                || session_id.contains(secret)
1268                || created_at.to_string().contains(secret)
1269                || cwd.contains(secret)
1270                || boot_system_prompt.contains(secret)
1271                || llm.base_url.contains(secret)
1272                || llm.model.contains(secret)
1273                || llm.api_key_env.contains(secret)
1274                || skills.iter().any(|skill| {
1275                    skill.name.contains(secret)
1276                        || skill.description.contains(secret)
1277                        || skill.path.display().to_string().contains(secret)
1278                        || skill.contents.contains(secret)
1279                })
1280        }
1281        SessionRecord::ProviderSettings {
1282            timestamp,
1283            model,
1284            effort,
1285        } => {
1286            timestamp.to_string().contains(secret)
1287                || model.contains(secret)
1288                || effort
1289                    .as_deref()
1290                    .is_some_and(|value| value.contains(secret))
1291        }
1292        SessionRecord::Message { timestamp, message } => {
1293            timestamp.to_string().contains(secret) || message_contains_secret(message, secret)
1294        }
1295        SessionRecord::Interruption {
1296            timestamp,
1297            reason,
1298            phase,
1299            assistant_text,
1300            tool_calls,
1301            tool_results,
1302        } => {
1303            timestamp.to_string().contains(secret)
1304                || reason.contains(secret)
1305                || phase.contains(secret)
1306                || assistant_text.contains(secret)
1307                || tool_calls.iter().any(|call| {
1308                    call.id.contains(secret)
1309                        || call.name.contains(secret)
1310                        || call.arguments.contains(secret)
1311                })
1312                || tool_results.iter().any(|observation| {
1313                    observation.id.contains(secret)
1314                        || observation.name.contains(secret)
1315                        || json_value_contains_secret(&observation.result, secret)
1316                })
1317        }
1318        SessionRecord::Compaction {
1319            timestamp,
1320            summary,
1321            first_kept_message,
1322            tokens_before,
1323        } => {
1324            timestamp.to_string().contains(secret)
1325                || summary.contains(secret)
1326                || first_kept_message.to_string().contains(secret)
1327                || tokens_before.to_string().contains(secret)
1328        }
1329        SessionRecord::BackgroundResultPending(pending) => {
1330            pending.timestamp.to_string().contains(secret)
1331                || pending.completion_id.contains(secret)
1332                || pending.task_id.contains(secret)
1333                || pending.child_session_id.contains(secret)
1334                || pending.task.contains(secret)
1335                || json_value_contains_secret(&pending.result, secret)
1336                || pending.completed_at.to_string().contains(secret)
1337        }
1338        SessionRecord::BackgroundResultDelivered(delivered) => {
1339            delivered.timestamp.to_string().contains(secret)
1340                || delivered.completion_id.contains(secret)
1341                || delivered.logical_turn_id.contains(secret)
1342        }
1343    }
1344}
1345
1346fn message_contains_secret(message: &ChatMessage, secret: &str) -> bool {
1347    message.role.contains(secret)
1348        || message
1349            .content
1350            .as_deref()
1351            .is_some_and(|content| content.contains(secret))
1352        || message.reasoning_details.as_ref().is_some_and(|details| {
1353            details
1354                .iter()
1355                .any(|detail| json_value_contains_secret(detail, secret))
1356        })
1357        || message
1358            .name
1359            .as_deref()
1360            .is_some_and(|name| name.contains(secret))
1361        || message
1362            .tool_call_id
1363            .as_deref()
1364            .is_some_and(|id| id.contains(secret))
1365        || message.tool_calls.iter().any(|call| {
1366            call.id.contains(secret)
1367                || call.name.contains(secret)
1368                || call.arguments.contains(secret)
1369                || tool_arguments_contain_secret(&call.arguments, secret)
1370        })
1371}
1372
1373fn tool_arguments_contain_secret(arguments: &str, secret: &str) -> bool {
1374    serde_json::from_str::<Value>(arguments)
1375        .ok()
1376        .is_some_and(|value| json_value_contains_secret(&value, secret))
1377}
1378
1379fn json_value_contains_secret(value: &Value, secret: &str) -> bool {
1380    match value {
1381        Value::String(text) => text.contains(secret),
1382        Value::Array(values) => values
1383            .iter()
1384            .any(|value| json_value_contains_secret(value, secret)),
1385        Value::Object(object) => {
1386            object.keys().any(|key| key.contains(secret))
1387                || object
1388                    .values()
1389                    .any(|value| json_value_contains_secret(value, secret))
1390        }
1391        Value::Number(number) => number.to_string().contains(secret),
1392        Value::Bool(_) | Value::Null => false,
1393    }
1394}
1395
1396fn session_error(message: impl Into<String>, secret: Option<&str>) -> SessionError {
1397    let message = message.into();
1398    SessionError::new(redact_secret(&message, secret))
1399}
1400
1401fn session_header_rejected(secret: &str) -> SessionError {
1402    session_error("session header rejected", Some(secret))
1403}
1404
1405fn session_record_rejected(secret: &str) -> SessionError {
1406    session_error("session record rejected", Some(secret))
1407}
1408
1409fn open_session_for_append(path: &Path) -> Result<File, SessionError> {
1410    let mut options = OpenOptions::new();
1411    options.write(true).append(true);
1412    #[cfg(unix)]
1413    options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
1414    #[cfg(not(unix))]
1415    ensure_not_symlink(path)?;
1416
1417    let file = options.open(path)?;
1418    let metadata = file.metadata()?;
1419    if !metadata.is_file() {
1420        return Err(SessionError::new(
1421            "session file is not a regular private file",
1422        ));
1423    }
1424    #[cfg(unix)]
1425    if metadata.permissions().mode() & 0o777 != 0o600 {
1426        return Err(SessionError::new(
1427            "session file is not a regular private file",
1428        ));
1429    }
1430    Ok(file)
1431}
1432
1433fn write_json_record<T: Serialize>(file: &mut File, record: &T) -> Result<(), SessionError> {
1434    let line = serde_json::to_string(record)
1435        .map_err(|error| SessionError::new(format!("unable to encode session record: {error}")))?;
1436    file.write_all(line.as_bytes())?;
1437    file.write_all(b"\n")?;
1438    file.flush()?;
1439    Ok(())
1440}
1441
1442fn write_record(file: &mut File, record: &SessionRecord) -> Result<(), SessionError> {
1443    write_json_record(file, record)
1444}
1445
1446fn child_record_contains_secret<T: Serialize>(record: &T, secret: &str) -> bool {
1447    !secret.is_empty()
1448        && serde_json::to_vec(record)
1449            .ok()
1450            .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
1451}
1452
1453fn background_result_messages(pending: &BackgroundResultPending) -> [ChatMessage; 2] {
1454    let call_id = format!("background-result-{}", pending.completion_id);
1455    let arguments = serde_json::json!({
1456        "completion_id": pending.completion_id,
1457        "task_id": pending.task_id,
1458        "child_session_id": pending.child_session_id,
1459    })
1460    .to_string();
1461    let content = serde_json::json!({
1462        "completion_id": pending.completion_id,
1463        "task_id": pending.task_id,
1464        "child_session_id": pending.child_session_id,
1465        "task": pending.task,
1466        "status": pending.status,
1467        "result": pending.result,
1468        "completed_at": pending.completed_at,
1469    })
1470    .to_string();
1471    [
1472        ChatMessage::assistant(
1473            String::new(),
1474            vec![ChatToolCall {
1475                id: call_id.clone(),
1476                name: BACKGROUND_RESULT_TOOL_NAME.to_owned(),
1477                arguments,
1478            }],
1479        ),
1480        ChatMessage::tool(call_id, BACKGROUND_RESULT_TOOL_NAME.to_owned(), content),
1481    ]
1482}
1483
1484const COMPACTION_SUMMARY_PREFIX: &str = "<context_compaction>\nThe earlier conversation was compacted. Treat the following summary as authoritative context for the continued turn.\n\n";
1485const COMPACTION_SUMMARY_SUFFIX: &str = "\n</context_compaction>";
1486
1487fn compaction_summary_message(summary: &str) -> ChatMessage {
1488    ChatMessage::user(format!(
1489        "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
1490    ))
1491}
1492
1493fn safe_message_summary(message: &ChatMessage) -> String {
1494    let role = message.role.as_str();
1495    let text = message
1496        .content
1497        .as_deref()
1498        .or_else(|| message.tool_calls.first().map(|call| call.name.as_str()))
1499        .unwrap_or("");
1500    let mut summary = text.chars().take(120).collect::<String>();
1501    if text.chars().count() > 120 {
1502        summary.push('…');
1503    }
1504    format!("{role}: {summary}")
1505}
1506
1507pub fn sessions_dir(home: &Path) -> PathBuf {
1508    home.join(".lucy").join("sessions")
1509}
1510
1511pub fn validate_session_id(id: &str) -> Result<(), SessionError> {
1512    if id.is_empty()
1513        || !id.chars().all(|character| {
1514            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
1515        })
1516    {
1517        return Err(SessionError::new("session id contains invalid characters"));
1518    }
1519    Ok(())
1520}
1521
1522fn new_session_id() -> String {
1523    let timestamp = now();
1524    let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
1525    format!("{timestamp}-{}-{counter}", std::process::id())
1526}
1527
1528fn now() -> u64 {
1529    SystemTime::now()
1530        .duration_since(UNIX_EPOCH)
1531        .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64)
1532        .unwrap_or(0)
1533}
1534
1535#[cfg(test)]
1536mod tests {
1537    use super::*;
1538    use crate::config::LlmSettings;
1539    #[cfg(unix)]
1540    use std::ffi::CString;
1541    #[cfg(unix)]
1542    use std::os::unix::ffi::OsStrExt;
1543    #[cfg(unix)]
1544    use std::os::unix::fs::{symlink, PermissionsExt};
1545    use std::sync::atomic::{AtomicU64, Ordering};
1546    use std::time::{SystemTime, UNIX_EPOCH};
1547
1548    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1549
1550    fn temporary_home() -> PathBuf {
1551        loop {
1552            let stamp = SystemTime::now()
1553                .duration_since(UNIX_EPOCH)
1554                .expect("clock")
1555                .as_nanos();
1556            let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1557            let path = std::env::temp_dir().join(format!(
1558                "lucy-session-{stamp}-{}-{counter}",
1559                std::process::id()
1560            ));
1561            match fs::create_dir(&path) {
1562                Ok(()) => return path,
1563                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1564                Err(error) => panic!("temp home: {error}"),
1565            }
1566        }
1567    }
1568
1569    #[cfg(unix)]
1570    #[test]
1571    fn append_rejects_a_non_private_opened_session_file_without_chmod() {
1572        let home = temporary_home();
1573        let cwd = std::env::current_dir().expect("cwd");
1574        let llm = LlmSettings {
1575            base_url: "http://localhost".to_owned(),
1576            model: "model".to_owned(),
1577            api_key_env: "LUCY_APPEND_TEST_KEY".to_owned(),
1578            effort: None,
1579        };
1580        let mut session =
1581            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1582        fs::set_permissions(&session.path, fs::Permissions::from_mode(0o644))
1583            .expect("make session group-readable");
1584
1585        let error = session
1586            .append_message(ChatMessage::user("must not append".to_owned()))
1587            .expect_err("unsafe permissions should be rejected");
1588        assert!(error.to_string().contains("private"));
1589        assert_eq!(
1590            fs::metadata(&session.path)
1591                .expect("session metadata")
1592                .permissions()
1593                .mode()
1594                & 0o777,
1595            0o644
1596        );
1597
1598        fs::remove_dir_all(home).expect("remove temp home");
1599    }
1600
1601    #[cfg(unix)]
1602    #[test]
1603    fn append_rejects_a_symlinked_session_path() {
1604        let home = temporary_home();
1605        let cwd = std::env::current_dir().expect("cwd");
1606        let llm = LlmSettings {
1607            base_url: "http://localhost".to_owned(),
1608            model: "model".to_owned(),
1609            api_key_env: "LUCY_APPEND_LINK_KEY".to_owned(),
1610            effort: None,
1611        };
1612        let mut session =
1613            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1614        let target = home.join("append-target.jsonl");
1615        fs::write(&target, "target\n").expect("target file");
1616        fs::remove_file(&session.path).expect("remove session path");
1617        symlink(&target, &session.path).expect("session symlink");
1618
1619        session
1620            .append_message(ChatMessage::user("must not append".to_owned()))
1621            .expect_err("symlink should be rejected");
1622        assert_eq!(
1623            fs::read_to_string(&target).expect("target contents"),
1624            "target\n"
1625        );
1626
1627        fs::remove_dir_all(home).expect("remove temp home");
1628    }
1629
1630    #[cfg(unix)]
1631    #[test]
1632    fn append_rejects_a_fifo_without_blocking() {
1633        let home = temporary_home();
1634        let cwd = std::env::current_dir().expect("cwd");
1635        let llm = LlmSettings {
1636            base_url: "http://localhost".to_owned(),
1637            model: "model".to_owned(),
1638            api_key_env: "LUCY_APPEND_FIFO_KEY".to_owned(),
1639            effort: None,
1640        };
1641        let mut session =
1642            Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1643        fs::remove_file(&session.path).expect("remove session path");
1644        let fifo_path = CString::new(session.path.as_os_str().as_bytes()).expect("FIFO path");
1645        let result = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) };
1646        assert_eq!(result, 0, "mkfifo: {:?}", io::Error::last_os_error());
1647
1648        session
1649            .append_message(ChatMessage::user("must not append".to_owned()))
1650            .expect_err("FIFO should be rejected without a blocking open");
1651
1652        fs::remove_dir_all(home).expect("remove temp home");
1653    }
1654
1655    #[cfg(unix)]
1656    #[test]
1657    fn rejects_symlinked_session_files_and_directories() {
1658        let home = temporary_home();
1659        let directory = home.join(".lucy/sessions");
1660        fs::create_dir_all(&directory).expect("sessions directory");
1661        let target = home.join("session-target.jsonl");
1662        fs::write(&target, "not a session\n").expect("target session");
1663        let path = directory.join("linked.jsonl");
1664        symlink(&target, &path).expect("session symlink");
1665        assert!(Session::resume(&home, "linked").is_err());
1666        assert!(Session::list(&home).expect("list sessions").is_empty());
1667        fs::remove_file(path).expect("remove session symlink");
1668        fs::remove_file(target).expect("remove target session");
1669        fs::remove_dir_all(home).expect("remove temp home");
1670
1671        let home = temporary_home();
1672        let lucy = home.join(".lucy");
1673        fs::create_dir(&lucy).expect("Lucy directory");
1674        let target = home.join("sessions-target");
1675        fs::create_dir(&target).expect("target sessions directory");
1676        symlink(&target, lucy.join("sessions")).expect("sessions directory symlink");
1677        assert!(Session::list(&home).is_err());
1678        fs::remove_file(lucy.join("sessions")).expect("remove sessions directory symlink");
1679        fs::remove_dir(target).expect("remove target sessions directory");
1680        fs::remove_dir(lucy).expect("remove Lucy directory");
1681        fs::remove_dir(home).expect("remove temp home");
1682    }
1683
1684    #[test]
1685    fn resume_rejects_duplicate_header_as_an_invalid_record() {
1686        let home = temporary_home();
1687        let sessions = home.join(".lucy/sessions");
1688        fs::create_dir_all(&sessions).expect("sessions");
1689        let id = "duplicate-header";
1690        let environment = format!("LUCY_DUPLICATE_HEADER_{}", std::process::id());
1691        let secret = "provider-secret";
1692        std::env::set_var(&environment, secret);
1693        let header = format!(
1694            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}"}}}}"#
1695        );
1696        fs::write(sessions.join(format!("{id}.jsonl")), format!("{header}\n"))
1697            .expect("duplicate header");
1698
1699        let error = Session::resume(&home, id).expect_err("duplicate header should be rejected");
1700        assert_eq!(error.to_string(), "invalid session record at line 1");
1701
1702        std::env::remove_var(environment);
1703        fs::remove_dir_all(home).expect("cleanup");
1704    }
1705
1706    #[test]
1707    fn creates_appends_resumes_and_lists_jsonl_session() {
1708        let home = temporary_home();
1709        let cwd = std::env::current_dir().expect("cwd");
1710        let llm = LlmSettings {
1711            base_url: "http://localhost:1234/api/v1".to_owned(),
1712            model: "test-model".to_owned(),
1713            api_key_env: "TEST_KEY".to_owned(),
1714            effort: None,
1715        };
1716        let mut session =
1717            Session::create(&home, &cwd, "stable prompt".to_owned(), llm.clone()).expect("create");
1718        #[cfg(unix)]
1719        {
1720            use std::os::unix::fs::PermissionsExt;
1721            assert_eq!(
1722                fs::metadata(sessions_dir(&home))
1723                    .expect("sessions directory metadata")
1724                    .permissions()
1725                    .mode()
1726                    & 0o777,
1727                0o700
1728            );
1729            assert_eq!(
1730                fs::metadata(&session.path)
1731                    .expect("session file metadata")
1732                    .permissions()
1733                    .mode()
1734                    & 0o777,
1735                0o600
1736            );
1737        }
1738        let id = session.id.clone();
1739        session
1740            .append_message(ChatMessage::user("first".to_owned()))
1741            .expect("append user");
1742        session
1743            .append_message(ChatMessage::assistant("last".to_owned(), Vec::new()))
1744            .expect("append assistant");
1745
1746        let resumed = Session::resume(&home, &id).expect("resume");
1747        assert_eq!(resumed.boot_system_prompt, "stable prompt");
1748        assert_eq!(resumed.llm, llm);
1749        assert_eq!(resumed.messages.len(), 2);
1750        assert_eq!(resumed.cwd, fs::canonicalize(cwd).expect("canonical cwd"));
1751        let listed = Session::list(&home).expect("list");
1752        assert_eq!(listed.len(), 1);
1753        assert_eq!(listed[0].session_id, id);
1754        assert!(listed[0]
1755            .first_message
1756            .as_deref()
1757            .is_some_and(|summary| summary.contains("first")));
1758        assert!(Session::resume(&home, "missing").is_err());
1759
1760        let file = fs::read_to_string(resumed.path).expect("session file");
1761        assert!(file.lines().count() >= 3);
1762        assert!(!file.contains("TEST_KEY_VALUE"));
1763        fs::remove_dir_all(home).expect("remove temp home");
1764    }
1765
1766    #[test]
1767    fn compaction_appends_a_boundary_and_reconstructs_only_retained_messages() {
1768        let home = temporary_home();
1769        let cwd = std::env::current_dir().expect("cwd");
1770        let llm = LlmSettings {
1771            base_url: "http://localhost".to_owned(),
1772            model: "model".to_owned(),
1773            api_key_env: "LUCY_COMPACTION_KEY".to_owned(),
1774            effort: None,
1775        };
1776        let mut session =
1777            Session::create_with_secret(&home, &cwd, "stable prompt".to_owned(), llm, None)
1778                .expect("create");
1779        session
1780            .append_message(ChatMessage::user("old request".to_owned()))
1781            .expect("old user");
1782        session
1783            .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1784            .expect("old assistant");
1785        session
1786            .append_message(ChatMessage::user("recent request".to_owned()))
1787            .expect("recent user");
1788        session
1789            .append_message(ChatMessage::assistant(
1790                "recent answer".to_owned(),
1791                Vec::new(),
1792            ))
1793            .expect("recent assistant");
1794
1795        session
1796            .append_compaction("old work summary".to_owned(), 2, 123)
1797            .expect("append compaction");
1798
1799        let provider_messages = session.provider_messages();
1800        assert_eq!(provider_messages[0].role, "system");
1801        assert_eq!(provider_messages[1].role, "user");
1802        assert!(provider_messages[1]
1803            .content
1804            .as_deref()
1805            .is_some_and(|content| content.contains("old work summary")));
1806        let provider_text = provider_messages
1807            .iter()
1808            .filter_map(|message| message.content.as_deref())
1809            .collect::<Vec<_>>()
1810            .join("\n");
1811        assert!(!provider_text.contains("old request"));
1812        assert!(!provider_text.contains("old answer"));
1813        assert!(provider_text.contains("recent request"));
1814        assert!(provider_text.contains("recent answer"));
1815        assert!(matches!(
1816            session.history.last(),
1817            Some(SessionHistoryRecord::Compaction(CompactionRecord {
1818                first_kept_message: 2,
1819                tokens_before: 123,
1820                ..
1821            }))
1822        ));
1823
1824        let resumed = Session::resume(&home, &session.id).expect("resume");
1825        assert_eq!(resumed.provider_messages(), provider_messages);
1826        assert_eq!(resumed.messages.len(), 4, "history remains append-only");
1827        fs::remove_dir_all(home).expect("cleanup");
1828    }
1829
1830    #[test]
1831    fn compaction_rejects_a_secret_in_the_summary_without_appending() {
1832        let home = temporary_home();
1833        let cwd = std::env::current_dir().expect("cwd");
1834        let key_env = format!("LUCY_COMPACTION_SECRET_{}", std::process::id());
1835        let secret = "provider-secret";
1836        std::env::set_var(&key_env, secret);
1837        let llm = LlmSettings {
1838            base_url: "http://localhost".to_owned(),
1839            model: "model".to_owned(),
1840            api_key_env: key_env.clone(),
1841            effort: None,
1842        };
1843        let mut session =
1844            Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, Some(secret))
1845                .expect("create");
1846        session
1847            .append_message(ChatMessage::user("one".to_owned()))
1848            .expect("user");
1849        let before = fs::read_to_string(&session.path).expect("session bytes");
1850
1851        let error = session
1852            .append_compaction(secret.to_owned(), 0, 1)
1853            .expect_err("secret summary should be rejected");
1854        assert!(error.to_string().contains("session record rejected"));
1855        assert_eq!(
1856            fs::read_to_string(&session.path).expect("session bytes"),
1857            before
1858        );
1859        assert!(!session
1860            .history
1861            .iter()
1862            .any(|record| matches!(record, SessionHistoryRecord::Compaction(_))));
1863
1864        std::env::remove_var(key_env);
1865        fs::remove_dir_all(home).expect("cleanup");
1866    }
1867
1868    #[test]
1869    fn background_results_persist_and_materialize_once_at_delivery_position() {
1870        let home = temporary_home();
1871        let cwd = std::env::current_dir().expect("cwd");
1872        let llm = LlmSettings {
1873            base_url: "http://localhost".to_owned(),
1874            model: "model".to_owned(),
1875            api_key_env: "LUCY_BACKGROUND_RESULT_KEY".to_owned(),
1876            effort: None,
1877        };
1878        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1879            .expect("create");
1880        session
1881            .append_message(ChatMessage::user("original request".to_owned()))
1882            .expect("user");
1883        let pending = BackgroundResultPending {
1884            timestamp: 0,
1885            completion_id: "completion-1".to_owned(),
1886            task_id: "subagent-1".to_owned(),
1887            child_session_id: "child-1".to_owned(),
1888            task: "inspect".to_owned(),
1889            status: ChildSessionStatus::Completed,
1890            result: serde_json::json!({"output":"done"}),
1891            completed_at: 10,
1892        };
1893        assert!(session
1894            .append_background_result_pending(pending.clone())
1895            .expect("pending"));
1896        let undelivered = session.undelivered_background_results();
1897        assert_eq!(undelivered.len(), 1);
1898        assert_eq!(undelivered[0].completion_id, pending.completion_id);
1899        assert_eq!(undelivered[0].result, pending.result);
1900        let mut collision = pending.clone();
1901        collision.child_session_id = "different-child".to_owned();
1902        assert!(session
1903            .append_background_result_pending(collision)
1904            .expect_err("identity collision rejected")
1905            .to_string()
1906            .contains("identity collision"));
1907        assert!(session
1908            .append_background_result_delivered(
1909                "completion-1",
1910                "turn-1".to_owned(),
1911                BackgroundResultDelivery::Synthetic,
1912            )
1913            .expect("delivered"));
1914        assert!(!session
1915            .append_background_result_delivered(
1916                "completion-1",
1917                "turn-1".to_owned(),
1918                BackgroundResultDelivery::Synthetic,
1919            )
1920            .expect("duplicate is idempotent"));
1921        session
1922            .append_message(ChatMessage::user("later request".to_owned()))
1923            .expect("later user");
1924
1925        let messages = session.provider_messages();
1926        let synthetic = messages
1927            .iter()
1928            .position(|message| {
1929                message.role == "assistant"
1930                    && message
1931                        .tool_calls
1932                        .first()
1933                        .is_some_and(|call| call.name == BACKGROUND_RESULT_TOOL_NAME)
1934            })
1935            .expect("synthetic call");
1936        assert_eq!(messages[synthetic + 1].role, "tool");
1937        assert_eq!(
1938            messages[synthetic + 1].name.as_deref(),
1939            Some(BACKGROUND_RESULT_TOOL_NAME)
1940        );
1941        assert_eq!(
1942            messages[synthetic + 2].content.as_deref(),
1943            Some("later request")
1944        );
1945        assert!(session.undelivered_background_results().is_empty());
1946
1947        let resumed = Session::resume(&home, &session.id).expect("resume");
1948        assert_eq!(resumed.provider_messages(), messages);
1949        assert!(resumed.undelivered_background_results().is_empty());
1950        fs::remove_dir_all(home).expect("cleanup");
1951    }
1952
1953    #[test]
1954    fn background_result_rejects_a_secret_without_appending() {
1955        let home = temporary_home();
1956        let cwd = std::env::current_dir().expect("cwd");
1957        let secret = "provider-secret";
1958        let llm = LlmSettings {
1959            base_url: "http://localhost".to_owned(),
1960            model: "model".to_owned(),
1961            api_key_env: "LUCY_BACKGROUND_RESULT_SECRET".to_owned(),
1962            effort: None,
1963        };
1964        let mut session =
1965            Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, Some(secret))
1966                .expect("create");
1967        let before = fs::read_to_string(&session.path).expect("before");
1968        let error = session
1969            .append_background_result_pending(BackgroundResultPending {
1970                timestamp: 0,
1971                completion_id: "completion-1".to_owned(),
1972                task_id: "subagent-1".to_owned(),
1973                child_session_id: "child-1".to_owned(),
1974                task: "inspect".to_owned(),
1975                status: ChildSessionStatus::Completed,
1976                result: serde_json::json!({"output":secret}),
1977                completed_at: 10,
1978            })
1979            .expect_err("secret result rejected");
1980        assert!(error.to_string().contains("session record rejected"));
1981        assert_eq!(fs::read_to_string(&session.path).expect("after"), before);
1982        assert!(session.undelivered_background_results().is_empty());
1983        fs::remove_dir_all(home).expect("cleanup");
1984    }
1985
1986    #[test]
1987    fn reasoning_details_round_trip_through_session_and_provider_history() {
1988        let home = temporary_home();
1989        let cwd = std::env::current_dir().expect("cwd");
1990        let llm = LlmSettings {
1991            base_url: "http://localhost".to_owned(),
1992            model: "model".to_owned(),
1993            api_key_env: "LUCY_REASONING_DETAILS_KEY".to_owned(),
1994            effort: None,
1995        };
1996        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1997            .expect("create");
1998        let details = vec![serde_json::json!({
1999            "type": "reasoning.text",
2000            "text": "provider detail"
2001        })];
2002        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
2003        assistant.reasoning_details = Some(details.clone());
2004        session.append_message(assistant).expect("assistant");
2005
2006        let resumed = Session::resume(&home, &session.id).expect("resume");
2007        assert_eq!(resumed.messages[0].reasoning_details, Some(details.clone()));
2008        let provider_assistant = resumed
2009            .provider_messages()
2010            .into_iter()
2011            .find(|message| message.role == "assistant")
2012            .expect("provider assistant");
2013        assert_eq!(provider_assistant.reasoning_details, Some(details));
2014        fs::remove_dir_all(home).expect("remove temp home");
2015    }
2016
2017    #[test]
2018    fn append_rejects_secrets_nested_in_reasoning_details() {
2019        let home = temporary_home();
2020        let cwd = std::env::current_dir().expect("cwd");
2021        let llm = LlmSettings {
2022            base_url: "http://localhost".to_owned(),
2023            model: "model".to_owned(),
2024            api_key_env: "LUCY_REASONING_SECRET_KEY".to_owned(),
2025            effort: None,
2026        };
2027        let mut session = Session::create_with_secret(
2028            &home,
2029            &cwd,
2030            "prompt".to_owned(),
2031            llm,
2032            Some("provider-secret"),
2033        )
2034        .expect("create");
2035        let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
2036        assistant.reasoning_details = Some(vec![serde_json::json!({
2037            "type": "reasoning.text",
2038            "text": "provider-secret"
2039        })]);
2040        let error = session
2041            .append_message(assistant)
2042            .expect_err("secret reasoning details");
2043        assert_eq!(error.to_string(), "session record rejected");
2044        fs::remove_dir_all(home).expect("remove temp home");
2045    }
2046
2047    #[test]
2048    fn child_session_persists_parent_link_transcript_and_terminal_status() {
2049        let home = temporary_home();
2050        let cwd = std::env::current_dir().expect("cwd");
2051        let key_env = format!("LUCY_CHILD_SESSION_KEY_{}", std::process::id());
2052        std::env::set_var(&key_env, "provider-secret");
2053        let llm = LlmSettings {
2054            base_url: "http://localhost".to_owned(),
2055            model: "model".to_owned(),
2056            api_key_env: key_env.clone(),
2057            effort: Some("medium".to_owned()),
2058        };
2059        let mut child = ChildSession::create(
2060            &home,
2061            "parent-session",
2062            &cwd,
2063            "boot context".to_owned(),
2064            llm,
2065            "inspect the worker".to_owned(),
2066            Some("provider-secret"),
2067        )
2068        .expect("child session");
2069        child
2070            .append_message(ChatMessage::user("inspect the worker".to_owned()))
2071            .expect("task message");
2072        child
2073            .append_message(ChatMessage::assistant("done".to_owned(), Vec::new()))
2074            .expect("assistant message");
2075        child
2076            .append_status(
2077                ChildSessionStatus::Completed,
2078                None,
2079                Some(serde_json::json!({"output":"done"})),
2080            )
2081            .expect("status");
2082
2083        let raw = fs::read_to_string(&child.path).expect("child JSONL");
2084        assert!(raw.contains("\"record\":\"subagent_session\""));
2085        assert!(raw.contains("\"parent_session_id\":\"parent-session\""));
2086        assert!(raw.contains("\"session_kind\":\"subagent\""));
2087        assert!(raw.contains("\"status\":\"completed\""));
2088        assert!(!raw.contains("provider-secret"));
2089        assert_eq!(child.provider_messages().len(), 3);
2090        assert_eq!(child.status, ChildSessionStatus::Completed);
2091
2092        std::env::remove_var(key_env);
2093        fs::remove_dir_all(home).expect("remove temp home");
2094    }
2095
2096    #[test]
2097    fn interruption_records_are_valid_and_resume_in_file_order_without_provider_fragments() {
2098        let home = temporary_home();
2099        let cwd = std::env::current_dir().expect("cwd");
2100        let llm = LlmSettings {
2101            base_url: "http://localhost".to_owned(),
2102            model: "model".to_owned(),
2103            api_key_env: "LUCY_NO_SESSION_KEY".to_owned(),
2104            effort: None,
2105        };
2106        let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
2107            .expect("create");
2108        session
2109            .append_message(ChatMessage::user("hello".to_owned()))
2110            .expect("user");
2111        session
2112            .append_interruption(InterruptionRecord {
2113                timestamp: 0,
2114                reason: "user_cancelled".to_owned(),
2115                phase: "provider_stream".to_owned(),
2116                assistant_text: "partial answer".to_owned(),
2117                tool_calls: vec![ChatToolCall {
2118                    id: "partial-call".to_owned(),
2119                    name: "cmd".to_owned(),
2120                    arguments: "{\"command\":".to_owned(),
2121                }],
2122                tool_results: Vec::new(),
2123            })
2124            .expect("interruption");
2125
2126        session
2127            .append_message(ChatMessage::assistant(
2128                String::new(),
2129                vec![ChatToolCall {
2130                    id: "call-1".to_owned(),
2131                    name: "cmd".to_owned(),
2132                    arguments: r#"{"command":"sleep 1"}"#.to_owned(),
2133                }],
2134            ))
2135            .expect("assistant tool call");
2136        session
2137            .append_interruption(InterruptionRecord {
2138                timestamp: 0,
2139                reason: "user_cancelled".to_owned(),
2140                phase: "cmd".to_owned(),
2141                assistant_text: String::new(),
2142                tool_calls: Vec::new(),
2143                tool_results: vec![SessionToolResult {
2144                    id: "call-1".to_owned(),
2145                    name: "cmd".to_owned(),
2146                    result: serde_json::json!({"canceled": true}),
2147                }],
2148            })
2149            .expect("command interruption");
2150
2151        let raw = fs::read_to_string(&session.path).expect("session JSONL");
2152        for line in raw.lines() {
2153            serde_json::from_str::<Value>(line).expect("valid JSONL record");
2154        }
2155        let resumed = Session::resume(&home, &session.id).expect("resume");
2156        assert_eq!(resumed.history.len(), 4);
2157        assert!(matches!(
2158            resumed.history[0],
2159            SessionHistoryRecord::Message { .. }
2160        ));
2161        assert!(matches!(
2162            resumed.history[1],
2163            SessionHistoryRecord::Interruption { .. }
2164        ));
2165        assert_eq!(resumed.messages.len(), 2);
2166        let provider_messages = resumed.provider_messages();
2167        assert_eq!(provider_messages.len(), 4);
2168        assert!(provider_messages.iter().any(|message| {
2169            message.role == "tool" && message.tool_call_id.as_deref() == Some("call-1")
2170        }));
2171        assert!(!resumed.provider_messages().iter().any(|message| {
2172            message
2173                .tool_calls
2174                .iter()
2175                .any(|call| call.id == "partial-call")
2176        }));
2177        fs::remove_dir_all(home).expect("remove temp home");
2178    }
2179}