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