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