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