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