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