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