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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113pub struct CompactionRecord {
114 pub timestamp: u64,
115 pub summary: String,
116 pub first_kept_message: usize,
119 pub tokens_before: usize,
120}
121
122#[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 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 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}