1use std::io::{BufWriter, Write};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use uuid::Uuid;
10
11use super::lifecycle::{read_last_message_uuid, SessionLock};
12use super::serialize::{CompactBoundaryEntry, TranscriptEntry};
13use super::{
14 chrono_lite_now, hash_tool_specs, workspace_slug, SessionMeta, SessionStatus, UsageMeta,
15};
16
17use crate::event::{AgentEvent, EventSink};
18use crate::llm::ToolSpec;
19use crate::message::Message;
20
21pub struct SessionWriter {
27 session_id: String,
28 session_dir: PathBuf,
29 writer: BufWriter<std::fs::File>,
30 message_count: u64,
31 last_uuid: Option<String>,
34 cumulative_usage: UsageMeta,
36 first_prompt: Option<String>,
38 last_prompt: Option<String>,
40 _lock: Option<SessionLock>,
44 name: Option<String>,
46}
47
48impl SessionWriter {
49 pub fn create(
59 workspace: &Path,
60 goal: &str,
61 model: &str,
62 provider: &str,
63 ) -> std::io::Result<Self> {
64 Self::create_with_tools(workspace, goal, model, provider, &[], None)
65 }
66
67 pub fn create_with_tools(
82 workspace: &Path,
83 goal: &str,
84 model: &str,
85 provider: &str,
86 tool_specs: &[ToolSpec],
87 preset: Option<&str>,
88 ) -> std::io::Result<Self> {
89 let slug = workspace_slug(workspace);
90 let session_id = format!("{}-{}", super::filesystem_safe_timestamp(), slug);
91 let sessions_root = crate::paths::user_sessions_dir(workspace)
94 .map_err(|e| std::io::Error::other(format!("user_sessions_dir: {e}")))?;
95 let session_dir = sessions_root.join(&slug).join(&session_id);
96
97 std::fs::create_dir_all(&session_dir)?;
98
99 let lock = SessionLock::acquire(&session_dir)?;
103
104 let jsonl_path = session_dir.join("transcript.jsonl");
105 let file = std::fs::OpenOptions::new()
106 .create(true)
107 .append(true)
108 .open(&jsonl_path)?;
109
110 let now = chrono_lite_now();
111 let tool_registry_hash = if tool_specs.is_empty() {
112 None
113 } else {
114 Some(hash_tool_specs(tool_specs))
115 };
116 let meta = SessionMeta {
117 schema_version: super::SUPPORTED_SESSION_SCHEMA_VERSION,
118 session_id: session_id.clone(),
119 goal: goal.to_string(),
120 model: model.to_string(),
121 provider: provider.to_string(),
122 created_at: now.clone(),
123 updated_at: now.clone(),
124 message_count: 0,
125 status: SessionStatus::Active,
126 tool_registry_hash,
127 first_prompt: None,
128 last_prompt: None,
129 cost: None,
130 preset: preset.map(|s| s.to_string()),
131 name: None,
132 };
133
134 let meta_path = session_dir.join(".meta.json");
136 let meta_json = serde_json::to_string_pretty(&meta)
137 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
138 crate::atomic::atomic_write(&meta_path, meta_json.as_bytes())?;
139
140 Ok(Self {
141 session_id,
142 session_dir,
143 writer: BufWriter::new(file),
144 message_count: 0,
145 last_uuid: None,
146 cumulative_usage: UsageMeta::default(),
147 first_prompt: None,
148 last_prompt: None,
149 _lock: Some(lock),
150 name: None,
151 })
152 }
153
154 pub fn open_existing(session_dir: &Path) -> std::io::Result<Self> {
170 let meta = super::reader::SessionReader::load_meta(session_dir)?;
171
172 let lock = SessionLock::acquire(session_dir)?;
173
174 let jsonl_path = session_dir.join("transcript.jsonl");
175
176 let last_uuid = read_last_message_uuid(&jsonl_path);
179
180 let file = std::fs::OpenOptions::new()
181 .create(true)
182 .append(true)
183 .open(&jsonl_path)?;
184
185 Ok(Self {
186 session_id: meta.session_id,
187 session_dir: session_dir.to_path_buf(),
188 writer: BufWriter::new(file),
189 message_count: meta.message_count,
190 last_uuid,
191 cumulative_usage: UsageMeta::default(),
192 first_prompt: meta.first_prompt,
193 last_prompt: meta.last_prompt,
194 _lock: Some(lock),
195 name: meta.name,
196 })
197 }
198
199 pub fn append(
216 &mut self,
217 msg: &Message,
218 parent_uuid_override: Option<&str>,
219 usage: Option<&UsageMeta>,
220 ) -> std::io::Result<String> {
221 self.append_with_audit(msg, None, parent_uuid_override, usage)
222 }
223
224 pub fn append_with_audit(
227 &mut self,
228 msg: &Message,
229 audit: Option<crate::tools::AuditMeta>,
230 parent_uuid_override: Option<&str>,
231 usage: Option<&UsageMeta>,
232 ) -> std::io::Result<String> {
233 self.message_count += 1;
234 let msg_id = format!("msg_{:03}", self.message_count);
235
236 let parent_id = if self.message_count > 1 {
237 Some(format!("msg_{:03}", self.message_count - 1))
238 } else {
239 None
240 };
241
242 let new_uuid = Uuid::new_v4().to_string();
244 let parent_uuid = parent_uuid_override
245 .map(|s| s.to_string())
246 .or_else(|| self.last_uuid.clone());
247
248 let source_tool_assistant_uuid = if matches!(msg.role, crate::message::Role::Tool) {
250 parent_uuid.clone()
252 } else {
253 None
254 };
255
256 let role_str = match msg.role {
257 crate::message::Role::System => "system",
258 crate::message::Role::User => "user",
259 crate::message::Role::Assistant => "assistant",
260 crate::message::Role::Tool => "tool",
261 };
262
263 if matches!(msg.role, crate::message::Role::User) {
265 let prompt: String = msg.content.chars().take(200).collect();
266 if self.first_prompt.is_none() {
267 self.first_prompt = Some(prompt.clone());
268 if self.name.is_none() {
273 let title: String = prompt.chars().take(60).collect();
274 self.name = Some(title);
275 }
276 }
277 self.last_prompt = Some(prompt);
278 }
279
280 if let Some(u) = usage {
282 self.cumulative_usage.accumulate(u);
283 }
284
285 let entry = TranscriptEntry {
286 uuid: new_uuid.clone(),
287 parent_uuid,
288 source_tool_assistant_uuid,
289 id: msg_id,
290 parent_id,
291 role: role_str.to_string(),
292 content: msg.content.clone(),
293 tool_calls: msg.tool_calls.clone(),
294 tool_call_id: msg.tool_call_id.clone(),
295 reasoning_content: msg.reasoning_content.clone(),
296 usage: usage.cloned(),
297 timestamp: chrono_lite_now(),
298 audit,
299 };
300
301 let line = serde_json::to_string(&entry)
302 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
303 self.writer.write_all(line.as_bytes())?;
304 self.writer.write_all(b"\n")?;
305 self.writer.flush()?;
306
307 self.last_uuid = Some(new_uuid.clone());
309
310 if matches!(
315 msg.role,
316 crate::message::Role::User | crate::message::Role::Assistant
317 ) {
318 let _ = self.bump_updated_at();
319 }
320
321 Ok(new_uuid)
322 }
323
324 pub fn write_compact_boundary(
331 &mut self,
332 turn: u32,
333 compacted_count: usize,
334 summary_uuid: Option<&str>,
335 ) -> std::io::Result<()> {
336 let entry = CompactBoundaryEntry {
337 entry_type: "system".to_string(),
338 subtype: "compact_boundary".to_string(),
339 turn: Some(turn),
340 compacted_count: Some(compacted_count),
341 summary_uuid: summary_uuid.map(|s| s.to_string()),
342 timestamp: chrono_lite_now(),
343 };
344 let line = serde_json::to_string(&entry)
345 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
346 self.writer.write_all(line.as_bytes())?;
347 self.writer.write_all(b"\n")?;
348 self.writer.flush()
349 }
350
351 fn bump_updated_at(&self) -> std::io::Result<()> {
356 let meta_path = self.session_dir.join(".meta.json");
357 let bytes = std::fs::read(&meta_path)?;
358 let mut meta: SessionMeta = serde_json::from_slice(&bytes)
359 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
360 meta.updated_at = chrono_lite_now();
361 meta.message_count = self.message_count;
362 if self.first_prompt.is_some() {
365 meta.first_prompt = self.first_prompt.clone();
366 }
367 if self.last_prompt.is_some() {
368 meta.last_prompt = self.last_prompt.clone();
369 }
370 if self.name.is_some() && meta.name.is_none() {
373 meta.name = self.name.clone();
374 }
375 let json = serde_json::to_string_pretty(&meta)
376 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
377 crate::atomic::atomic_write(&meta_path, json.as_bytes())
378 }
379
380 pub fn finish(&mut self, status: SessionStatus) -> std::io::Result<()> {
389 self.writer.flush()?;
390
391 let meta_path = self.session_dir.join(".meta.json");
394 let bytes = std::fs::read(&meta_path)?;
395 let mut meta: SessionMeta = serde_json::from_slice(&bytes)
396 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
397 meta.updated_at = chrono_lite_now();
398 meta.message_count = self.message_count;
399 meta.status = status;
400 if self.first_prompt.is_some() {
402 meta.first_prompt = self.first_prompt.clone();
403 }
404 if self.last_prompt.is_some() {
405 meta.last_prompt = self.last_prompt.clone();
406 }
407 if !self.cumulative_usage.is_zero() {
409 let mut cost = meta.cost.take().unwrap_or_default();
410 cost.accumulate(&self.cumulative_usage);
411 meta.cost = Some(cost);
412 }
413 if self.name.is_some() {
415 meta.name = self.name.clone();
416 }
417
418 let meta_json = serde_json::to_string_pretty(&meta)
419 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
420 crate::atomic::atomic_write(&meta_path, meta_json.as_bytes())
421 }
422
423 pub fn set_name(&mut self, name: impl Into<String>) {
426 self.name = Some(name.into());
427 }
428
429 pub fn session_id(&self) -> &str {
431 &self.session_id
432 }
433
434 pub fn session_dir(&self) -> &Path {
436 &self.session_dir
437 }
438
439 pub fn message_count(&self) -> u64 {
441 self.message_count
442 }
443
444 pub fn last_uuid(&self) -> Option<&str> {
446 self.last_uuid.as_deref()
447 }
448}
449
450pub struct SessionPersistenceSink {
473 writer: Arc<std::sync::Mutex<SessionWriter>>,
474}
475
476impl SessionPersistenceSink {
477 pub fn new(writer: Arc<std::sync::Mutex<SessionWriter>>) -> Self {
479 Self { writer }
480 }
481}
482
483#[async_trait::async_trait]
484impl EventSink for SessionPersistenceSink {
485 async fn emit(&self, event: AgentEvent) {
486 match event {
487 AgentEvent::MessageAppended { message, usage } => {
488 let result = {
489 match self.writer.lock() {
490 Ok(mut w) => w.append_with_audit(&message, None, None, usage.as_ref()),
491 Err(poisoned) => {
492 let mut w = poisoned.into_inner();
493 w.append_with_audit(&message, None, None, usage.as_ref())
494 }
495 }
496 };
497 if let Err(e) = result {
498 tracing::error!("session persistence: failed to append message: {e}");
499 }
500 }
501 AgentEvent::MessageAppendedWithAudit { message, audit } => {
502 let result = {
504 match self.writer.lock() {
505 Ok(mut w) => w.append_with_audit(&message, Some(audit), None, None),
506 Err(poisoned) => {
507 let mut w = poisoned.into_inner();
508 w.append_with_audit(&message, Some(audit), None, None)
509 }
510 }
511 };
512 if let Err(e) = result {
513 tracing::error!("session persistence: failed to append audited message: {e}");
514 }
515 }
516 AgentEvent::CompactionBoundary {
517 turn,
518 compacted_count,
519 summary_uuid,
520 ..
521 } => {
522 let result = match self.writer.lock() {
525 Ok(mut w) => {
526 w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
527 }
528 Err(poisoned) => {
529 let mut w = poisoned.into_inner();
530 w.write_compact_boundary(turn, compacted_count, summary_uuid.as_deref())
531 }
532 };
533 if let Err(e) = result {
534 tracing::error!("session persistence: failed to write compact_boundary: {e}");
535 }
536 }
537 _ => {}
538 }
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use crate::message::{Message, Role};
546 use crate::session::SessionReader;
547
548 #[test]
549 fn session_writer_creates_meta_and_jsonl() {
550 let tmp = crate::test_util::IsolatedWorkspace::new();
551 let ws = tmp.path();
552
553 let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
554
555 let session_dir = writer.session_dir().to_path_buf();
556 assert!(session_dir.join("transcript.jsonl").is_file());
557 assert!(session_dir.join(".meta.json").is_file());
558
559 let meta = SessionReader::load_meta(&session_dir).unwrap();
561 assert_eq!(meta.goal, "test goal");
562 assert_eq!(meta.model, "gpt-4o");
563 assert_eq!(meta.provider, "openai");
564 assert_eq!(meta.message_count, 0);
565 assert_eq!(meta.status, SessionStatus::Active);
566
567 writer.finish(SessionStatus::Completed).unwrap();
568
569 let meta = SessionReader::load_meta(&session_dir).unwrap();
570 assert_eq!(meta.message_count, 0);
571 assert_eq!(meta.status, SessionStatus::Completed);
572 }
573
574 #[test]
575 fn session_writer_appends_lines() {
576 let tmp = crate::test_util::IsolatedWorkspace::new();
577 let ws = tmp.path();
578
579 let mut writer = SessionWriter::create(ws, "test", "gpt-4o", "openai").unwrap();
580
581 let id1 = writer.append(&Message::user("hello"), None, None).unwrap();
582 assert_eq!(id1.len(), 36, "uuid should be 36 chars");
584
585 let id2 = writer
586 .append(&Message::assistant("hi there"), None, None)
587 .unwrap();
588 assert_eq!(id2.len(), 36);
589 assert_ne!(id1, id2, "each message gets a unique uuid");
590
591 let session_dir = writer.session_dir().to_path_buf();
592 writer.finish(SessionStatus::Completed).unwrap();
593
594 let entries = SessionReader::load_transcript(&session_dir).unwrap();
596 assert_eq!(entries.len(), 2);
597 assert_eq!(entries[0].id, "msg_001");
598 assert_eq!(entries[0].parent_id, None);
599 assert_eq!(entries[0].parent_uuid, None, "root has no parent_uuid");
600 assert!(!entries[0].uuid.is_empty(), "uuid must be present");
601 assert_eq!(entries[0].role, "user");
602 assert_eq!(entries[0].content, "hello");
603
604 assert_eq!(entries[1].id, "msg_002");
605 assert_eq!(entries[1].parent_id, Some("msg_001".to_string()));
606 assert_eq!(
607 entries[1].parent_uuid,
608 Some(entries[0].uuid.clone()),
609 "parent_uuid points to first entry"
610 );
611 assert_eq!(entries[1].role, "assistant");
612 assert_eq!(entries[1].content, "hi there");
613 }
614
615 #[test]
616 fn session_writer_finish_updates_meta() {
617 let tmp = crate::test_util::IsolatedWorkspace::new();
618 let ws = tmp.path();
619
620 let mut writer = SessionWriter::create(ws, "meta test", "gpt-4o", "openai").unwrap();
621 writer.append(&Message::user("msg1"), None, None).unwrap();
622 writer
623 .append(&Message::assistant("msg2"), None, None)
624 .unwrap();
625 let session_dir = writer.session_dir().to_path_buf();
626 writer.finish(SessionStatus::Completed).unwrap();
627
628 let meta = SessionReader::load_meta(&session_dir).unwrap();
629 assert_eq!(meta.message_count, 2);
630 assert_eq!(meta.status, SessionStatus::Completed);
631 }
632
633 #[test]
641 fn session_meta_preserves_preset_field() {
642 let tmp = crate::test_util::IsolatedWorkspace::new();
643 let ws = tmp.path();
644
645 let (_session_dir, preset_after) = {
651 let mut writer = SessionWriter::create_with_tools(
652 ws,
653 "preset run",
654 "deepseek-chat",
655 "openai",
656 &[],
657 Some("deepseek"),
658 )
659 .unwrap();
660 let dir = writer.session_dir().to_path_buf();
661 writer.finish(SessionStatus::Completed).unwrap();
662 let meta = SessionReader::load_meta(&dir).unwrap();
663 (dir, meta.preset)
664 };
665 assert_eq!(preset_after.as_deref(), Some("deepseek"));
666
667 let writer2 =
670 SessionWriter::create_with_tools(ws, "no preset run", "gpt-4o", "openai", &[], None)
671 .unwrap();
672 let session_dir2 = writer2.session_dir().to_path_buf();
673 drop(writer2);
674 let meta2 = SessionReader::load_meta(&session_dir2).unwrap();
675 assert!(meta2.preset.is_none());
676 let raw = std::fs::read_to_string(session_dir2.join(".meta.json")).unwrap();
677 assert!(
678 !raw.contains("\"preset\""),
679 "preset key should be absent when None, got: {raw}"
680 );
681 }
682
683 #[test]
686 fn session_name_autofills_from_first_prompt() {
687 let tmp = crate::test_util::IsolatedWorkspace::new();
688 let ws = tmp.path();
689
690 let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
691 let session_dir = writer.session_dir().to_path_buf();
692
693 assert!(writer.name.is_none());
695
696 writer
698 .append(&Message::user("hello world".to_string()), None, None)
699 .unwrap();
700 assert_eq!(writer.name.as_deref(), Some("hello world"));
701
702 writer.finish(SessionStatus::Completed).unwrap();
703
704 let meta = SessionReader::load_meta(&session_dir).unwrap();
706 assert_eq!(meta.name.as_deref(), Some("hello world"));
707 }
708
709 #[test]
711 fn explicit_name_not_overwritten_by_autofill() {
712 let tmp = crate::test_util::IsolatedWorkspace::new();
713 let ws = tmp.path();
714
715 let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
716 writer.set_name("my custom name".to_string());
717 let session_dir = writer.session_dir().to_path_buf();
718
719 writer
720 .append(&Message::user("hello world".to_string()), None, None)
721 .unwrap();
722 assert_eq!(writer.name.as_deref(), Some("my custom name"));
724
725 writer.finish(SessionStatus::Completed).unwrap();
726
727 let meta = SessionReader::load_meta(&session_dir).unwrap();
728 assert_eq!(meta.name.as_deref(), Some("my custom name"));
729 }
730
731 #[test]
733 fn autofill_name_truncates_long_prompt() {
734 let tmp = crate::test_util::IsolatedWorkspace::new();
735 let ws = tmp.path();
736
737 let long_prompt = "a".repeat(100);
738 let mut writer = SessionWriter::create(ws, "test goal", "gpt-4o", "openai").unwrap();
739 writer
740 .append(&Message::user(long_prompt.clone()), None, None)
741 .unwrap();
742 writer.finish(SessionStatus::Completed).unwrap();
743
744 let session_dir = writer.session_dir().to_path_buf();
745 let meta = SessionReader::load_meta(&session_dir).unwrap();
746 assert_eq!(
747 meta.name.as_deref(),
748 Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") );
750 }
751
752 #[test]
755 fn session_writer_accessor_methods_return_correct_values() {
756 let tmp = crate::test_util::IsolatedWorkspace::new();
759 let writer = SessionWriter::create(tmp.path(), "goal", "gpt-4o", "openai").unwrap();
760
761 assert!(
763 !writer.session_id().is_empty(),
764 "session_id must be non-empty"
765 );
766 assert!(writer.session_dir().exists());
768 assert!(writer.session_dir().ends_with(writer.session_id()));
769 assert_eq!(writer.message_count(), 0, "initial message_count must be 0");
771 assert!(
773 writer.last_uuid().is_none(),
774 "last_uuid must be None initially"
775 );
776 }
777
778 #[test]
779 fn write_compact_boundary_appends_line_to_transcript() {
780 let tmp = crate::test_util::IsolatedWorkspace::new();
782 let mut writer = SessionWriter::create(tmp.path(), "g", "gpt-4o", "openai").unwrap();
783 let session_dir = writer.session_dir().to_path_buf();
784 writer
785 .write_compact_boundary(5, 10, Some("uuid-summary-1"))
786 .unwrap();
787 let transcript_path = session_dir.join("transcript.jsonl");
788 let content = std::fs::read_to_string(&transcript_path).unwrap();
789 assert!(
790 content.contains("compact_boundary"),
791 "transcript must contain 'compact_boundary' marker"
792 );
793 assert!(
794 content.contains("uuid-summary-1"),
795 "transcript must contain the summary UUID"
796 );
797 }
798
799 fn make_isolated_writer() -> (
800 crate::test_util::IsolatedWorkspace,
801 Arc<std::sync::Mutex<SessionWriter>>,
802 ) {
803 let ws = crate::test_util::IsolatedWorkspace::new();
804 let writer = SessionWriter::create(ws.path(), "test goal", "gpt-4o", "openai").unwrap();
805 (ws, Arc::new(std::sync::Mutex::new(writer)))
806 }
807
808 #[tokio::test]
811 async fn message_appended_round_trips_through_sink() {
812 use crate::event::{AgentEvent, EventSink};
813 use crate::llm::ToolCall as LlmToolCall;
814
815 let (_ws, sw) = make_isolated_writer();
816 let sink = SessionPersistenceSink::new(sw.clone());
817
818 let tc = LlmToolCall {
819 id: "call_1".into(),
820 name: "my_tool".into(),
821 arguments: serde_json::json!({"x": 1}),
822 };
823 let msg = Message {
824 role: Role::Assistant,
825 content: "response text".into(),
826 tool_calls: vec![tc],
827 tool_call_id: None,
828 reasoning_content: Some("I thought about it".into()),
829 is_compaction_summary: false,
830 };
831 sink.emit(AgentEvent::MessageAppended {
832 message: msg.clone(),
833 usage: None,
834 })
835 .await;
836
837 sink.emit(AgentEvent::PlanConfirmed).await;
839
840 let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
841 drop(sink);
842 drop(sw);
843
844 let transcript = SessionReader::load_messages(&session_dir).unwrap();
845 assert_eq!(transcript.len(), 1, "exactly one message written");
846 let loaded = &transcript[0];
847 assert_eq!(loaded.content, "response text");
848 assert_eq!(
849 loaded.reasoning_content.as_deref(),
850 Some("I thought about it")
851 );
852 assert_eq!(loaded.tool_calls.len(), 1);
853 assert_eq!(loaded.tool_calls[0].name, "my_tool");
854 }
855
856 #[tokio::test]
859 async fn sink_recovers_from_poisoned_mutex() {
860 use crate::event::{AgentEvent, EventSink};
861
862 let (_ws, sw) = make_isolated_writer();
863 let session_dir = sw.lock().unwrap().session_dir().to_path_buf();
864
865 let sw2 = sw.clone();
867 let _ = std::panic::catch_unwind(move || {
868 let _guard = sw2.lock().unwrap();
869 panic!("intentional poison");
870 });
871 assert!(sw.is_poisoned(), "mutex must be poisoned after the panic");
872
873 let sink = SessionPersistenceSink::new(sw);
874 sink.emit(AgentEvent::MessageAppended {
876 message: Message::user("after poison"),
877 usage: None,
878 })
879 .await;
880
881 let transcript = SessionReader::load_messages(&session_dir).unwrap();
882 assert_eq!(transcript.len(), 1);
883 assert_eq!(transcript[0].content, "after poison");
884 }
885
886 #[test]
887 fn open_existing_resumes_message_count_and_uuid_chain() {
888 let tmp = crate::test_util::IsolatedWorkspace::new();
890 let session_dir = {
891 let mut w = SessionWriter::create(tmp.path(), "goal", "gpt-4o", "openai").unwrap();
892 w.append(&Message::user("first".to_string()), None, None)
893 .unwrap();
894 w.append(&Message::assistant("second".to_string()), None, None)
895 .unwrap();
896 let d = w.session_dir().to_path_buf();
897 w.finish(SessionStatus::Completed).unwrap();
898 d
899 };
900
901 let mut w2 = SessionWriter::open_existing(&session_dir).unwrap();
903 assert_eq!(
904 w2.message_count(),
905 2,
906 "open_existing must recover message_count from meta"
907 );
908 assert!(
910 w2.last_uuid().is_some(),
911 "open_existing must recover last_uuid from transcript"
912 );
913
914 w2.append(&Message::user("third".to_string()), None, None)
916 .unwrap();
917 assert_eq!(
918 w2.message_count(),
919 3,
920 "appending after open_existing must increment count"
921 );
922 }
923
924 #[test]
925 fn append_with_audit_includes_audit_fields_in_jsonl() {
926 let tmp = crate::test_util::IsolatedWorkspace::new();
929 let mut w = SessionWriter::create(tmp.path(), "goal", "gpt-4o", "openai").unwrap();
930 let session_dir = w.session_dir().to_path_buf();
931
932 let audit = crate::tools::AuditMeta::synthetic_unknown_tool("Read");
935 let tool_msg = Message::tool_result("tc-audit-1", "file contents");
936 w.append_with_audit(&tool_msg, Some(audit), None, None)
937 .unwrap();
938 w.finish(SessionStatus::Completed).unwrap();
939
940 let raw = std::fs::read_to_string(session_dir.join("transcript.jsonl")).unwrap();
942 assert!(
943 raw.contains("\"audit\""),
944 "audit field must appear in transcript when provided; raw={raw}"
945 );
946 }
947}