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 for message in &mut messages {
733 if message.role == "assistant" && !message.tool_calls.is_empty() {
734 message
735 .tool_calls
736 .retain(|call| completed_tool_calls.contains(&call.id));
737 }
738 }
739 messages
740 }
741
742 pub fn list(home: &Path) -> Result<Vec<SessionMetadata>, SessionError> {
743 Self::list_with_secret(home, None)
744 }
745
746 pub fn list_with_secret(
747 home: &Path,
748 external_secret: Option<&str>,
749 ) -> Result<Vec<SessionMetadata>, SessionError> {
750 let directory = sessions_dir(home);
751 let lucy_directory = lucy_dir(home);
752 ensure_not_symlink(&lucy_directory)?;
753 if lucy_directory.is_dir() {
754 ensure_private_dir(&lucy_directory)?;
755 }
756 ensure_not_symlink(&directory)?;
757 if directory.is_dir() {
758 ensure_private_dir(&directory)?;
759 }
760 let entries = match fs::read_dir(&directory) {
761 Ok(entries) => entries,
762 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
763 Err(_error) => return Err(SessionError::new("unable to list sessions")),
764 };
765
766 let mut paths = Vec::new();
767 for entry in entries {
768 let entry = entry?;
769 let path = entry.path();
770 let metadata = match fs::symlink_metadata(&path) {
771 Ok(metadata) => metadata,
772 Err(_) => continue,
773 };
774 if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
775 && metadata.is_file()
776 {
777 paths.push(path);
778 }
779 }
780 paths.sort();
781
782 let mut metadata = Vec::new();
783 for path in paths {
784 let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
785 continue;
786 };
787 let Ok(session) = Self::resume_with_secret(home, id, external_secret) else {
788 continue;
789 };
790 metadata.push(SessionMetadata {
791 record_type: "session_metadata",
792 session_id: session.id,
793 created_at: session.created_at,
794 updated_at: session.updated_at,
795 first_message: session.messages.first().map(safe_message_summary),
796 last_message: session.messages.last().map(safe_message_summary),
797 });
798 }
799 Ok(metadata)
800 }
801}
802
803struct DuplicateKeyValue(Value);
804
805impl<'de> Deserialize<'de> for DuplicateKeyValue {
806 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
807 where
808 D: Deserializer<'de>,
809 {
810 deserializer
811 .deserialize_any(DuplicateKeyValueVisitor)
812 .map(Self)
813 }
814}
815
816struct DuplicateKeyValueSeed;
817
818impl<'de> DeserializeSeed<'de> for DuplicateKeyValueSeed {
819 type Value = Value;
820
821 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
822 where
823 D: Deserializer<'de>,
824 {
825 deserializer.deserialize_any(DuplicateKeyValueVisitor)
826 }
827}
828
829struct DuplicateKeyValueVisitor;
830
831impl<'de> Visitor<'de> for DuplicateKeyValueVisitor {
832 type Value = Value;
833
834 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
835 formatter.write_str("a valid JSON value")
836 }
837
838 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
839 Ok(Value::Bool(value))
840 }
841
842 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
843 Ok(Value::Number(value.into()))
844 }
845
846 fn visit_i128<E>(self, value: i128) -> Result<Self::Value, E>
847 where
848 E: de::Error,
849 {
850 serde_json::Number::from_i128(value)
851 .map(Value::Number)
852 .ok_or_else(|| de::Error::custom("JSON number out of range"))
853 }
854
855 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
856 Ok(Value::Number(value.into()))
857 }
858
859 fn visit_u128<E>(self, value: u128) -> Result<Self::Value, E>
860 where
861 E: de::Error,
862 {
863 serde_json::Number::from_u128(value)
864 .map(Value::Number)
865 .ok_or_else(|| de::Error::custom("JSON number out of range"))
866 }
867
868 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
869 where
870 E: de::Error,
871 {
872 Ok(serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number))
873 }
874
875 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> {
876 Ok(Value::String(value.to_owned()))
877 }
878
879 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> {
880 Ok(Value::String(value))
881 }
882
883 fn visit_none<E>(self) -> Result<Self::Value, E> {
884 Ok(Value::Null)
885 }
886
887 fn visit_unit<E>(self) -> Result<Self::Value, E> {
888 Ok(Value::Null)
889 }
890
891 fn visit_seq<A>(self, mut access: A) -> Result<Self::Value, A::Error>
892 where
893 A: SeqAccess<'de>,
894 {
895 let mut values = Vec::new();
896 while let Some(value) = access.next_element_seed(DuplicateKeyValueSeed)? {
897 values.push(value);
898 }
899 Ok(Value::Array(values))
900 }
901
902 fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
903 where
904 A: MapAccess<'de>,
905 {
906 let mut values = serde_json::Map::new();
907 while let Some(key) = access.next_key::<String>()? {
908 if values.contains_key(&key) {
909 return Err(de::Error::custom("duplicate object key"));
910 }
911 let value = access.next_value_seed(DuplicateKeyValueSeed)?;
912 values.insert(key, value);
913 }
914 Ok(Value::Object(values))
915 }
916}
917
918fn parse_json_value(line: &str) -> Result<Value, serde_json::Error> {
919 serde_json::from_str::<DuplicateKeyValue>(line).map(|value| value.0)
920}
921
922fn session_header_secret(raw: &[u8]) -> Option<String> {
923 let line = raw
924 .split(|byte| *byte == b'\n')
925 .find(|line| !line.iter().all(|byte| byte.is_ascii_whitespace()))?;
926 let value = parse_json_value(std::str::from_utf8(line).ok()?).ok()?;
927 let api_key_env = value
928 .get("llm")
929 .and_then(|llm| llm.get("api_key_env"))
930 .and_then(Value::as_str)?;
931 let secret = std::env::var(api_key_env).ok()?;
932 (!secret.is_empty()).then_some(secret)
933}
934
935fn bytes_contain_secret(raw: &[u8], secret: &str) -> bool {
936 let secret = secret.as_bytes();
937 !secret.is_empty() && raw.windows(secret.len()).any(|window| window == secret)
938}
939
940fn record_contains_secret(record: &SessionRecord, secret: &str) -> bool {
941 if secret.is_empty() {
942 return false;
943 }
944 if serde_json::to_vec(record)
945 .ok()
946 .is_some_and(|serialized| bytes_contain_secret(&serialized, secret))
947 {
948 return true;
949 }
950 match record {
951 SessionRecord::Session {
952 version,
953 session_id,
954 created_at,
955 cwd,
956 boot_system_prompt,
957 llm,
958 skills,
959 } => {
960 version.to_string().contains(secret)
961 || session_id.contains(secret)
962 || created_at.to_string().contains(secret)
963 || cwd.contains(secret)
964 || boot_system_prompt.contains(secret)
965 || llm.base_url.contains(secret)
966 || llm.model.contains(secret)
967 || llm.api_key_env.contains(secret)
968 || skills.iter().any(|skill| {
969 skill.name.contains(secret)
970 || skill.description.contains(secret)
971 || skill.path.display().to_string().contains(secret)
972 || skill.contents.contains(secret)
973 })
974 }
975 SessionRecord::ProviderSettings {
976 timestamp,
977 model,
978 effort,
979 } => {
980 timestamp.to_string().contains(secret)
981 || model.contains(secret)
982 || effort
983 .as_deref()
984 .is_some_and(|value| value.contains(secret))
985 }
986 SessionRecord::Message { timestamp, message } => {
987 timestamp.to_string().contains(secret) || message_contains_secret(message, secret)
988 }
989 SessionRecord::Interruption {
990 timestamp,
991 reason,
992 phase,
993 assistant_text,
994 tool_calls,
995 tool_results,
996 } => {
997 timestamp.to_string().contains(secret)
998 || reason.contains(secret)
999 || phase.contains(secret)
1000 || assistant_text.contains(secret)
1001 || tool_calls.iter().any(|call| {
1002 call.id.contains(secret)
1003 || call.name.contains(secret)
1004 || call.arguments.contains(secret)
1005 })
1006 || tool_results.iter().any(|observation| {
1007 observation.id.contains(secret)
1008 || observation.name.contains(secret)
1009 || json_value_contains_secret(&observation.result, secret)
1010 })
1011 }
1012 SessionRecord::Compaction {
1013 timestamp,
1014 summary,
1015 first_kept_message,
1016 tokens_before,
1017 } => {
1018 timestamp.to_string().contains(secret)
1019 || summary.contains(secret)
1020 || first_kept_message.to_string().contains(secret)
1021 || tokens_before.to_string().contains(secret)
1022 }
1023 SessionRecord::LegacyPendingRecord { .. } | SessionRecord::LegacyDeliveredRecord { .. } => {
1024 false
1025 }
1026 }
1027}
1028
1029fn message_contains_secret(message: &ChatMessage, secret: &str) -> bool {
1030 message.role.contains(secret)
1031 || message
1032 .content
1033 .as_deref()
1034 .is_some_and(|content| content.contains(secret))
1035 || message.reasoning_details.as_ref().is_some_and(|details| {
1036 details
1037 .iter()
1038 .any(|detail| json_value_contains_secret(detail, secret))
1039 })
1040 || message
1041 .name
1042 .as_deref()
1043 .is_some_and(|name| name.contains(secret))
1044 || message
1045 .tool_call_id
1046 .as_deref()
1047 .is_some_and(|id| id.contains(secret))
1048 || message.tool_calls.iter().any(|call| {
1049 call.id.contains(secret)
1050 || call.name.contains(secret)
1051 || call.arguments.contains(secret)
1052 || tool_arguments_contain_secret(&call.arguments, secret)
1053 })
1054}
1055
1056fn tool_arguments_contain_secret(arguments: &str, secret: &str) -> bool {
1057 serde_json::from_str::<Value>(arguments)
1058 .ok()
1059 .is_some_and(|value| json_value_contains_secret(&value, secret))
1060}
1061
1062fn json_value_contains_secret(value: &Value, secret: &str) -> bool {
1063 match value {
1064 Value::String(text) => text.contains(secret),
1065 Value::Array(values) => values
1066 .iter()
1067 .any(|value| json_value_contains_secret(value, secret)),
1068 Value::Object(object) => {
1069 object.keys().any(|key| key.contains(secret))
1070 || object
1071 .values()
1072 .any(|value| json_value_contains_secret(value, secret))
1073 }
1074 Value::Number(number) => number.to_string().contains(secret),
1075 Value::Bool(_) | Value::Null => false,
1076 }
1077}
1078
1079fn session_error(message: impl Into<String>, secret: Option<&str>) -> SessionError {
1080 let message = message.into();
1081 SessionError::new(redact_secret(&message, secret))
1082}
1083
1084fn session_header_rejected(secret: &str) -> SessionError {
1085 session_error("session header rejected", Some(secret))
1086}
1087
1088fn session_record_rejected(secret: &str) -> SessionError {
1089 session_error("session record rejected", Some(secret))
1090}
1091
1092fn open_session_for_append(path: &Path) -> Result<File, SessionError> {
1093 let mut options = OpenOptions::new();
1094 options.write(true).append(true);
1095 #[cfg(unix)]
1096 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
1097 #[cfg(not(unix))]
1098 ensure_not_symlink(path)?;
1099
1100 let file = options.open(path)?;
1101 let metadata = file.metadata()?;
1102 if !metadata.is_file() {
1103 return Err(SessionError::new(
1104 "session file is not a regular private file",
1105 ));
1106 }
1107 #[cfg(unix)]
1108 if metadata.permissions().mode() & 0o777 != 0o600 {
1109 return Err(SessionError::new(
1110 "session file is not a regular private file",
1111 ));
1112 }
1113 Ok(file)
1114}
1115
1116fn write_json_record<T: Serialize>(file: &mut File, record: &T) -> Result<(), SessionError> {
1117 let line = serde_json::to_string(record)
1118 .map_err(|error| SessionError::new(format!("unable to encode session record: {error}")))?;
1119 file.write_all(line.as_bytes())?;
1120 file.write_all(b"\n")?;
1121 file.flush()?;
1122 Ok(())
1123}
1124
1125fn write_record(file: &mut File, record: &SessionRecord) -> Result<(), SessionError> {
1126 write_json_record(file, record)
1127}
1128
1129const COMPACTION_SUMMARY_PREFIX: &str = "<context_compaction>\nThe earlier conversation was compacted. Treat the following summary as authoritative context for the continued turn.\n\n";
1130const COMPACTION_SUMMARY_SUFFIX: &str = "\n</context_compaction>";
1131
1132fn compaction_summary_message(summary: &str) -> ChatMessage {
1133 ChatMessage::user(format!(
1134 "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
1135 ))
1136}
1137
1138fn safe_message_summary(message: &ChatMessage) -> String {
1139 let role = message.role.as_str();
1140 let text = message
1141 .content
1142 .as_deref()
1143 .or_else(|| message.tool_calls.first().map(|call| call.name.as_str()))
1144 .unwrap_or("");
1145 let mut summary = text.chars().take(120).collect::<String>();
1146 if text.chars().count() > 120 {
1147 summary.push('…');
1148 }
1149 format!("{role}: {summary}")
1150}
1151
1152pub fn sessions_dir(home: &Path) -> PathBuf {
1153 home.join(".lucy").join("sessions")
1154}
1155
1156pub fn validate_session_id(id: &str) -> Result<(), SessionError> {
1157 if id.is_empty()
1158 || !id.chars().all(|character| {
1159 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
1160 })
1161 {
1162 return Err(SessionError::new("session id contains invalid characters"));
1163 }
1164 Ok(())
1165}
1166
1167fn new_session_id() -> String {
1168 let timestamp = now();
1169 let counter = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
1170 format!("{timestamp}-{}-{counter}", std::process::id())
1171}
1172
1173fn now() -> u64 {
1174 SystemTime::now()
1175 .duration_since(UNIX_EPOCH)
1176 .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64)
1177 .unwrap_or(0)
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use crate::config::LlmSettings;
1184 #[cfg(unix)]
1185 use std::ffi::CString;
1186 #[cfg(unix)]
1187 use std::os::unix::ffi::OsStrExt;
1188 #[cfg(unix)]
1189 use std::os::unix::fs::{symlink, PermissionsExt};
1190 use std::sync::atomic::{AtomicU64, Ordering};
1191 use std::time::{SystemTime, UNIX_EPOCH};
1192
1193 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1194
1195 fn temporary_home() -> PathBuf {
1196 loop {
1197 let stamp = SystemTime::now()
1198 .duration_since(UNIX_EPOCH)
1199 .expect("clock")
1200 .as_nanos();
1201 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
1202 let path = std::env::temp_dir().join(format!(
1203 "lucy-session-{stamp}-{}-{counter}",
1204 std::process::id()
1205 ));
1206 match fs::create_dir(&path) {
1207 Ok(()) => return path,
1208 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1209 Err(error) => panic!("temp home: {error}"),
1210 }
1211 }
1212 }
1213
1214 #[cfg(unix)]
1215 #[test]
1216 fn append_rejects_a_non_private_opened_session_file_without_chmod() {
1217 let home = temporary_home();
1218 let cwd = std::env::current_dir().expect("cwd");
1219 let llm = LlmSettings {
1220 base_url: "http://localhost".to_owned(),
1221 model: "model".to_owned(),
1222 api_key_env: "LUCY_APPEND_TEST_KEY".to_owned(),
1223 effort: None,
1224 };
1225 let mut session =
1226 Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1227 fs::set_permissions(&session.path, fs::Permissions::from_mode(0o644))
1228 .expect("make session group-readable");
1229
1230 let error = session
1231 .append_message(ChatMessage::user("must not append".to_owned()))
1232 .expect_err("unsafe permissions should be rejected");
1233 assert!(error.to_string().contains("private"));
1234 assert_eq!(
1235 fs::metadata(&session.path)
1236 .expect("session metadata")
1237 .permissions()
1238 .mode()
1239 & 0o777,
1240 0o644
1241 );
1242
1243 fs::remove_dir_all(home).expect("remove temp home");
1244 }
1245
1246 #[cfg(unix)]
1247 #[test]
1248 fn append_rejects_a_symlinked_session_path() {
1249 let home = temporary_home();
1250 let cwd = std::env::current_dir().expect("cwd");
1251 let llm = LlmSettings {
1252 base_url: "http://localhost".to_owned(),
1253 model: "model".to_owned(),
1254 api_key_env: "LUCY_APPEND_LINK_KEY".to_owned(),
1255 effort: None,
1256 };
1257 let mut session =
1258 Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1259 let target = home.join("append-target.jsonl");
1260 fs::write(&target, "target\n").expect("target file");
1261 fs::remove_file(&session.path).expect("remove session path");
1262 symlink(&target, &session.path).expect("session symlink");
1263
1264 session
1265 .append_message(ChatMessage::user("must not append".to_owned()))
1266 .expect_err("symlink should be rejected");
1267 assert_eq!(
1268 fs::read_to_string(&target).expect("target contents"),
1269 "target\n"
1270 );
1271
1272 fs::remove_dir_all(home).expect("remove temp home");
1273 }
1274
1275 #[cfg(unix)]
1276 #[test]
1277 fn append_rejects_a_fifo_without_blocking() {
1278 let home = temporary_home();
1279 let cwd = std::env::current_dir().expect("cwd");
1280 let llm = LlmSettings {
1281 base_url: "http://localhost".to_owned(),
1282 model: "model".to_owned(),
1283 api_key_env: "LUCY_APPEND_FIFO_KEY".to_owned(),
1284 effort: None,
1285 };
1286 let mut session =
1287 Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create session");
1288 fs::remove_file(&session.path).expect("remove session path");
1289 let fifo_path = CString::new(session.path.as_os_str().as_bytes()).expect("FIFO path");
1290 let result = unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) };
1291 assert_eq!(result, 0, "mkfifo: {:?}", io::Error::last_os_error());
1292
1293 session
1294 .append_message(ChatMessage::user("must not append".to_owned()))
1295 .expect_err("FIFO should be rejected without a blocking open");
1296
1297 fs::remove_dir_all(home).expect("remove temp home");
1298 }
1299
1300 #[cfg(unix)]
1301 #[test]
1302 fn rejects_symlinked_session_files_and_directories() {
1303 let home = temporary_home();
1304 let directory = home.join(".lucy/sessions");
1305 fs::create_dir_all(&directory).expect("sessions directory");
1306 let target = home.join("session-target.jsonl");
1307 fs::write(&target, "not a session\n").expect("target session");
1308 let path = directory.join("linked.jsonl");
1309 symlink(&target, &path).expect("session symlink");
1310 assert!(Session::resume(&home, "linked").is_err());
1311 assert!(Session::list(&home).expect("list sessions").is_empty());
1312 fs::remove_file(path).expect("remove session symlink");
1313 fs::remove_file(target).expect("remove target session");
1314 fs::remove_dir_all(home).expect("remove temp home");
1315
1316 let home = temporary_home();
1317 let lucy = home.join(".lucy");
1318 fs::create_dir(&lucy).expect("Lucy directory");
1319 let target = home.join("sessions-target");
1320 fs::create_dir(&target).expect("target sessions directory");
1321 symlink(&target, lucy.join("sessions")).expect("sessions directory symlink");
1322 assert!(Session::list(&home).is_err());
1323 fs::remove_file(lucy.join("sessions")).expect("remove sessions directory symlink");
1324 fs::remove_dir(target).expect("remove target sessions directory");
1325 fs::remove_dir(lucy).expect("remove Lucy directory");
1326 fs::remove_dir(home).expect("remove temp home");
1327 }
1328
1329 #[test]
1330 fn resume_rejects_duplicate_header_as_an_invalid_record() {
1331 let home = temporary_home();
1332 let sessions = home.join(".lucy/sessions");
1333 fs::create_dir_all(&sessions).expect("sessions");
1334 let id = "duplicate-header";
1335 let environment = format!("LUCY_DUPLICATE_HEADER_{}", std::process::id());
1336 let secret = "provider-secret";
1337 std::env::set_var(&environment, secret);
1338 let header = format!(
1339 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}"}}}}"#
1340 );
1341 fs::write(sessions.join(format!("{id}.jsonl")), format!("{header}\n"))
1342 .expect("duplicate header");
1343
1344 let error = Session::resume(&home, id).expect_err("duplicate header should be rejected");
1345 assert_eq!(error.to_string(), "invalid session record at line 1");
1346
1347 std::env::remove_var(environment);
1348 fs::remove_dir_all(home).expect("cleanup");
1349 }
1350
1351 #[test]
1352 fn creates_appends_resumes_and_lists_jsonl_session() {
1353 let home = temporary_home();
1354 let cwd = std::env::current_dir().expect("cwd");
1355 let llm = LlmSettings {
1356 base_url: "http://localhost:1234/api/v1".to_owned(),
1357 model: "test-model".to_owned(),
1358 api_key_env: "TEST_KEY".to_owned(),
1359 effort: None,
1360 };
1361 let mut session =
1362 Session::create(&home, &cwd, "stable prompt".to_owned(), llm.clone()).expect("create");
1363 #[cfg(unix)]
1364 {
1365 use std::os::unix::fs::PermissionsExt;
1366 assert_eq!(
1367 fs::metadata(sessions_dir(&home))
1368 .expect("sessions directory metadata")
1369 .permissions()
1370 .mode()
1371 & 0o777,
1372 0o700
1373 );
1374 assert_eq!(
1375 fs::metadata(&session.path)
1376 .expect("session file metadata")
1377 .permissions()
1378 .mode()
1379 & 0o777,
1380 0o600
1381 );
1382 }
1383 let id = session.id.clone();
1384 session
1385 .append_message(ChatMessage::user("first".to_owned()))
1386 .expect("append user");
1387 session
1388 .append_message(ChatMessage::assistant("last".to_owned(), Vec::new()))
1389 .expect("append assistant");
1390
1391 let resumed = Session::resume(&home, &id).expect("resume");
1392 assert_eq!(resumed.boot_system_prompt, "stable prompt");
1393 assert_eq!(resumed.llm, llm);
1394 assert_eq!(resumed.messages.len(), 2);
1395 assert_eq!(resumed.cwd, fs::canonicalize(cwd).expect("canonical cwd"));
1396 let listed = Session::list(&home).expect("list");
1397 assert_eq!(listed.len(), 1);
1398 assert_eq!(listed[0].session_id, id);
1399 assert!(listed[0]
1400 .first_message
1401 .as_deref()
1402 .is_some_and(|summary| summary.contains("first")));
1403 assert!(Session::resume(&home, "missing").is_err());
1404
1405 let file = fs::read_to_string(resumed.path).expect("session file");
1406 assert!(file.lines().count() >= 3);
1407 assert!(!file.contains("TEST_KEY_VALUE"));
1408 fs::remove_dir_all(home).expect("remove temp home");
1409 }
1410
1411 #[test]
1412 fn resume_retains_historical_boot_system_prompt() {
1413 let home = temporary_home();
1414 let cwd = std::env::current_dir().expect("cwd");
1415 let llm = LlmSettings {
1416 base_url: "http://localhost".to_owned(),
1417 model: "model".to_owned(),
1418 api_key_env: "LUCY_HISTORICAL_PROMPT_KEY".to_owned(),
1419 effort: None,
1420 };
1421 let historical_prompt = "historical built-in boot prompt";
1422 let session =
1423 Session::create(&home, &cwd, historical_prompt.to_owned(), llm).expect("create");
1424
1425 let resumed = Session::resume(&home, &session.id).expect("resume");
1426 assert_eq!(resumed.boot_system_prompt, historical_prompt);
1427 assert_eq!(
1428 resumed.provider_messages()[0],
1429 ChatMessage::system(historical_prompt.to_owned())
1430 );
1431
1432 fs::remove_dir_all(home).expect("cleanup");
1433 }
1434
1435 #[test]
1436 fn provider_messages_downgrade_legacy_background_system_records() {
1437 let home = temporary_home();
1438 let cwd = std::env::current_dir().expect("cwd");
1439 let llm = LlmSettings {
1440 base_url: "http://localhost".to_owned(),
1441 model: "model".to_owned(),
1442 api_key_env: "LUCY_LEGACY_BACKGROUND_KEY".to_owned(),
1443 effort: None,
1444 };
1445 let mut session =
1446 Session::create(&home, &cwd, "genuine boot prompt".to_owned(), llm).expect("create");
1447 session
1448 .append_message(ChatMessage::system(format!(
1449 "{LEGACY_BACKGROUND_COMPLETION_PREFIX}. legacy output"
1450 )))
1451 .expect("append legacy background completion");
1452 let id = session.id.clone();
1453 let path = session.path.clone();
1454 let raw_before = fs::read_to_string(&path).expect("session JSONL");
1455 assert!(raw_before.contains(
1456 r#""role":"system","content":"Lucy background command completed. legacy output""#
1457 ));
1458
1459 let resumed = Session::resume(&home, &id).expect("resume");
1460 let provider_messages = resumed.provider_messages();
1461 assert_eq!(provider_messages.len(), 2);
1462 assert_eq!(provider_messages[0].role, "system");
1463 assert_eq!(
1464 provider_messages[0].content.as_deref(),
1465 Some("genuine boot prompt")
1466 );
1467 assert_eq!(provider_messages[1].role, crate::model::OBSERVATION_ROLE);
1468 assert_eq!(
1469 provider_messages[1].content.as_deref(),
1470 Some("Lucy background command completed. legacy output")
1471 );
1472 assert_eq!(
1473 fs::read_to_string(path).expect("session JSONL after provider messages"),
1474 raw_before
1475 );
1476 fs::remove_dir_all(home).expect("cleanup");
1477 }
1478
1479 #[test]
1480 fn provider_messages_drops_tool_calls_without_results() {
1481 let home = temporary_home();
1482 let cwd = std::env::current_dir().expect("cwd");
1483 let llm = LlmSettings {
1484 base_url: "http://localhost".to_owned(),
1485 model: "model".to_owned(),
1486 api_key_env: "LUCY_ORPHAN_TOOL_KEY".to_owned(),
1487 effort: None,
1488 };
1489 let mut session = Session::create(&home, &cwd, "prompt".to_owned(), llm).expect("create");
1490 let assistant = ChatMessage::assistant(
1492 "let me check".to_owned(),
1493 vec![ChatToolCall {
1494 id: "call-orphan".to_owned(),
1495 name: "cmd".to_owned(),
1496 arguments: r#"{"command":"pwd"}"#.to_owned(),
1497 }],
1498 );
1499 session.append_message(assistant).expect("append assistant");
1500 let assistant2 = ChatMessage::assistant(
1502 "done".to_owned(),
1503 vec![ChatToolCall {
1504 id: "call-done".to_owned(),
1505 name: "cmd".to_owned(),
1506 arguments: r#"{"command":"ls"}"#.to_owned(),
1507 }],
1508 );
1509 session
1510 .append_message(assistant2)
1511 .expect("append assistant2");
1512 session
1513 .append_message(ChatMessage::tool(
1514 "call-done".to_owned(),
1515 "cmd".to_owned(),
1516 r#"{"exit_code":0,"stdout":"ok"}"#.to_owned(),
1517 ))
1518 .expect("append tool result");
1519
1520 let messages = session.provider_messages();
1521 let assistants: Vec<_> = messages.iter().filter(|m| m.role == "assistant").collect();
1523 assert_eq!(assistants.len(), 2);
1524 assert!(
1526 assistants[0].tool_calls.is_empty(),
1527 "orphaned tool call should be removed"
1528 );
1529 assert_eq!(assistants[1].tool_calls.len(), 1);
1531 assert_eq!(assistants[1].tool_calls[0].id, "call-done");
1532 fs::remove_dir_all(home).expect("cleanup");
1533 }
1534
1535 #[test]
1536 fn compaction_appends_a_boundary_and_reconstructs_only_retained_messages() {
1537 let home = temporary_home();
1538 let cwd = std::env::current_dir().expect("cwd");
1539 let llm = LlmSettings {
1540 base_url: "http://localhost".to_owned(),
1541 model: "model".to_owned(),
1542 api_key_env: "LUCY_COMPACTION_KEY".to_owned(),
1543 effort: None,
1544 };
1545 let mut session =
1546 Session::create_with_secret(&home, &cwd, "stable prompt".to_owned(), llm, None)
1547 .expect("create");
1548 session
1549 .append_message(ChatMessage::user("old request".to_owned()))
1550 .expect("old user");
1551 session
1552 .append_message(ChatMessage::assistant("old answer".to_owned(), Vec::new()))
1553 .expect("old assistant");
1554 session
1555 .append_message(ChatMessage::user("recent request".to_owned()))
1556 .expect("recent user");
1557 session
1558 .append_message(ChatMessage::assistant(
1559 "recent answer".to_owned(),
1560 Vec::new(),
1561 ))
1562 .expect("recent assistant");
1563
1564 session
1565 .append_compaction("old work summary".to_owned(), 2, 123)
1566 .expect("append compaction");
1567
1568 let provider_messages = session.provider_messages();
1569 assert_eq!(provider_messages[0].role, "system");
1570 assert_eq!(provider_messages[1].role, "user");
1571 assert!(provider_messages[1]
1572 .content
1573 .as_deref()
1574 .is_some_and(|content| content.contains("old work summary")));
1575 let provider_text = provider_messages
1576 .iter()
1577 .filter_map(|message| message.content.as_deref())
1578 .collect::<Vec<_>>()
1579 .join("\n");
1580 assert!(!provider_text.contains("old request"));
1581 assert!(!provider_text.contains("old answer"));
1582 assert!(provider_text.contains("recent request"));
1583 assert!(provider_text.contains("recent answer"));
1584 assert!(matches!(
1585 session.history.last(),
1586 Some(SessionHistoryRecord::Compaction(CompactionRecord {
1587 first_kept_message: 2,
1588 tokens_before: 123,
1589 ..
1590 }))
1591 ));
1592
1593 let resumed = Session::resume(&home, &session.id).expect("resume");
1594 assert_eq!(resumed.provider_messages(), provider_messages);
1595 assert_eq!(resumed.messages.len(), 4, "history remains append-only");
1596 fs::remove_dir_all(home).expect("cleanup");
1597 }
1598
1599 #[test]
1600 fn compaction_rejects_a_secret_in_the_summary_without_appending() {
1601 let home = temporary_home();
1602 let cwd = std::env::current_dir().expect("cwd");
1603 let key_env = format!("LUCY_COMPACTION_SECRET_{}", std::process::id());
1604 let secret = "provider-secret";
1605 std::env::set_var(&key_env, secret);
1606 let llm = LlmSettings {
1607 base_url: "http://localhost".to_owned(),
1608 model: "model".to_owned(),
1609 api_key_env: key_env.clone(),
1610 effort: None,
1611 };
1612 let mut session =
1613 Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, Some(secret))
1614 .expect("create");
1615 session
1616 .append_message(ChatMessage::user("one".to_owned()))
1617 .expect("user");
1618 let before = fs::read_to_string(&session.path).expect("session bytes");
1619
1620 let error = session
1621 .append_compaction(secret.to_owned(), 0, 1)
1622 .expect_err("secret summary should be rejected");
1623 assert!(error.to_string().contains("session record rejected"));
1624 assert_eq!(
1625 fs::read_to_string(&session.path).expect("session bytes"),
1626 before
1627 );
1628 assert!(!session
1629 .history
1630 .iter()
1631 .any(|record| matches!(record, SessionHistoryRecord::Compaction(_))));
1632
1633 std::env::remove_var(key_env);
1634 fs::remove_dir_all(home).expect("cleanup");
1635 }
1636
1637 #[test]
1638 fn reasoning_details_round_trip_through_session_and_provider_history() {
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_REASONING_DETAILS_KEY".to_owned(),
1645 effort: None,
1646 };
1647 let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1648 .expect("create");
1649 let details = vec![serde_json::json!({
1650 "type": "reasoning.text",
1651 "text": "provider detail"
1652 })];
1653 let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1654 assistant.reasoning_details = Some(details.clone());
1655 session.append_message(assistant).expect("assistant");
1656
1657 let resumed = Session::resume(&home, &session.id).expect("resume");
1658 assert_eq!(resumed.messages[0].reasoning_details, Some(details.clone()));
1659 let provider_assistant = resumed
1660 .provider_messages()
1661 .into_iter()
1662 .find(|message| message.role == "assistant")
1663 .expect("provider assistant");
1664 assert_eq!(provider_assistant.reasoning_details, Some(details));
1665 fs::remove_dir_all(home).expect("remove temp home");
1666 }
1667
1668 #[test]
1669 fn append_rejects_secrets_nested_in_reasoning_details() {
1670 let home = temporary_home();
1671 let cwd = std::env::current_dir().expect("cwd");
1672 let llm = LlmSettings {
1673 base_url: "http://localhost".to_owned(),
1674 model: "model".to_owned(),
1675 api_key_env: "LUCY_REASONING_SECRET_KEY".to_owned(),
1676 effort: None,
1677 };
1678 let mut session = Session::create_with_secret(
1679 &home,
1680 &cwd,
1681 "prompt".to_owned(),
1682 llm,
1683 Some("provider-secret"),
1684 )
1685 .expect("create");
1686 let mut assistant = ChatMessage::assistant("answer".to_owned(), Vec::new());
1687 assistant.reasoning_details = Some(vec![serde_json::json!({
1688 "type": "reasoning.text",
1689 "text": "provider-secret"
1690 })]);
1691 let error = session
1692 .append_message(assistant)
1693 .expect_err("secret reasoning details");
1694 assert_eq!(error.to_string(), "session record rejected");
1695 fs::remove_dir_all(home).expect("remove temp home");
1696 }
1697
1698 #[test]
1699 fn interruption_records_are_valid_and_resume_in_file_order_without_provider_fragments() {
1700 let home = temporary_home();
1701 let cwd = std::env::current_dir().expect("cwd");
1702 let llm = LlmSettings {
1703 base_url: "http://localhost".to_owned(),
1704 model: "model".to_owned(),
1705 api_key_env: "LUCY_NO_SESSION_KEY".to_owned(),
1706 effort: None,
1707 };
1708 let mut session = Session::create_with_secret(&home, &cwd, "prompt".to_owned(), llm, None)
1709 .expect("create");
1710 session
1711 .append_message(ChatMessage::user("hello".to_owned()))
1712 .expect("user");
1713 session
1714 .append_interruption(InterruptionRecord {
1715 timestamp: 0,
1716 reason: "user_cancelled".to_owned(),
1717 phase: "provider_stream".to_owned(),
1718 assistant_text: "partial answer".to_owned(),
1719 tool_calls: vec![ChatToolCall {
1720 id: "partial-call".to_owned(),
1721 name: "cmd".to_owned(),
1722 arguments: "{\"command\":".to_owned(),
1723 }],
1724 tool_results: Vec::new(),
1725 })
1726 .expect("interruption");
1727
1728 session
1729 .append_message(ChatMessage::assistant(
1730 String::new(),
1731 vec![ChatToolCall {
1732 id: "call-1".to_owned(),
1733 name: "cmd".to_owned(),
1734 arguments: r#"{"command":"sleep 1"}"#.to_owned(),
1735 }],
1736 ))
1737 .expect("assistant tool call");
1738 session
1739 .append_interruption(InterruptionRecord {
1740 timestamp: 0,
1741 reason: "user_cancelled".to_owned(),
1742 phase: "cmd".to_owned(),
1743 assistant_text: String::new(),
1744 tool_calls: Vec::new(),
1745 tool_results: vec![SessionToolResult {
1746 id: "call-1".to_owned(),
1747 name: "cmd".to_owned(),
1748 result: serde_json::json!({"canceled": true}),
1749 }],
1750 })
1751 .expect("command interruption");
1752
1753 let raw = fs::read_to_string(&session.path).expect("session JSONL");
1754 for line in raw.lines() {
1755 serde_json::from_str::<Value>(line).expect("valid JSONL record");
1756 }
1757 let resumed = Session::resume(&home, &session.id).expect("resume");
1758 assert_eq!(resumed.history.len(), 4);
1759 assert!(matches!(
1760 resumed.history[0],
1761 SessionHistoryRecord::Message { .. }
1762 ));
1763 assert!(matches!(
1764 resumed.history[1],
1765 SessionHistoryRecord::Interruption { .. }
1766 ));
1767 assert_eq!(resumed.messages.len(), 2);
1768 let provider_messages = resumed.provider_messages();
1769 assert_eq!(provider_messages.len(), 4);
1770 assert!(provider_messages.iter().any(|message| {
1771 message.role == "tool" && message.tool_call_id.as_deref() == Some("call-1")
1772 }));
1773 assert!(!resumed.provider_messages().iter().any(|message| {
1774 message
1775 .tool_calls
1776 .iter()
1777 .any(|call| call.id == "partial-call")
1778 }));
1779 fs::remove_dir_all(home).expect("remove temp home");
1780 }
1781
1782 #[test]
1783 fn resume_ignores_legacy_internal_records_without_creating_relationships() {
1784 let home = temporary_home();
1785 let sessions = home.join(".lucy/sessions");
1786 fs::create_dir_all(&sessions).expect("sessions");
1787 let id = "legacy-main";
1788 let header = serde_json::json!({
1789 "record": "session",
1790 "version": 1,
1791 "session_id": id,
1792 "created_at": 1,
1793 "cwd": std::env::current_dir().expect("cwd").display().to_string(),
1794 "boot_system_prompt": "prompt",
1795 "llm": {
1796 "base_url": "http://localhost",
1797 "model": "model",
1798 "api_key_env": "LUCY_LEGACY_KEY"
1799 },
1800 "skills": []
1801 });
1802 let message = serde_json::json!({
1803 "record": "message",
1804 "timestamp": 2,
1805 "message": {"role":"user","content":"keep this"}
1806 });
1807 let legacy = serde_json::json!({
1808 "record": "background_result_pending",
1809 "timestamp": 3,
1810 "completion_id": "old-completion",
1811 "task_id": "old-task",
1812 "child_session_id": "old-child",
1813 "task": "old task",
1814 "status": "completed",
1815 "result": {"output":"old result"},
1816 "completed_at": 3
1817 });
1818 fs::write(
1819 sessions.join(format!("{id}.jsonl")),
1820 format!("{}\n{}\n{}\n", header, message, legacy),
1821 )
1822 .expect("legacy session");
1823
1824 let resumed = Session::resume(&home, id).expect("legacy session resumes");
1825 assert_eq!(resumed.messages.len(), 1);
1826 assert_eq!(resumed.messages[0].content.as_deref(), Some("keep this"));
1827 assert!(resumed
1828 .provider_messages()
1829 .iter()
1830 .all(|message| message.name.as_deref() != Some("background_result")));
1831 let listed = Session::list(&home).expect("list legacy session");
1832 assert_eq!(listed.len(), 1);
1833 assert_eq!(listed[0].session_id, id);
1834 fs::remove_dir_all(home).expect("cleanup");
1835 }
1836}