1use crate::exec::events::ThreadEvent;
2use crate::llm::provider::Message;
3use crate::utils::session_archive::{
4 SessionArchive, SessionArchiveMetadata, SessionForkMode, SessionListing,
5 find_session_by_identifier, list_recent_sessions, reserve_session_archive_identifier,
6 session_listing_matches_workspace,
7};
8use anyhow::{Result, anyhow};
9use parking_lot::Mutex;
10use serde::{Deserialize, Serialize};
11use std::collections::VecDeque;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use uuid::Uuid;
15use vtcode_macros::StringNewtype;
16
17const DEFAULT_EVENT_BUFFER_CAPACITY: usize = 512;
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, StringNewtype)]
21pub struct ThreadId(String);
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, StringNewtype)]
25pub struct SubmissionId(String);
26
27impl SubmissionId {
28 pub fn generate() -> Self {
30 Self(format!("sub-{}", Uuid::new_v4()))
31 }
32}
33
34impl Default for SubmissionId {
35 fn default() -> Self {
36 Self::generate()
37 }
38}
39
40#[derive(Debug, Clone)]
41pub struct ThreadEventRecord {
42 pub sequence: u64,
43 pub thread_id: ThreadId,
44 pub submission_id: Option<SubmissionId>,
45 pub turn_id: Option<String>,
46 pub event: ThreadEvent,
47}
48
49#[derive(Debug, Clone)]
50pub struct ThreadSnapshot {
51 pub thread_id: ThreadId,
52 pub metadata: Option<SessionArchiveMetadata>,
53 pub archive_listing: Option<SessionListing>,
54 pub messages: Vec<Message>,
55 pub loaded_skills: Vec<String>,
56 pub turn_in_flight: bool,
57}
58
59#[derive(Debug, Clone)]
60pub struct ThreadBootstrap {
61 pub metadata: Option<SessionArchiveMetadata>,
62 pub archive_listing: Option<SessionListing>,
63 pub messages: Vec<Message>,
64 pub loaded_skills: Vec<String>,
65}
66
67impl ThreadBootstrap {
68 pub fn new(metadata: Option<SessionArchiveMetadata>) -> Self {
69 Self {
70 metadata,
71 archive_listing: None,
72 messages: Vec::new(),
73 loaded_skills: Vec::new(),
74 }
75 }
76
77 pub fn from_listing(listing: SessionListing) -> Self {
78 Self {
79 metadata: Some(listing.snapshot.metadata.clone()),
80 messages: messages_from_session_listing(&listing),
81 loaded_skills: loaded_skills_from_session_listing(&listing),
82 archive_listing: Some(listing),
83 }
84 }
85
86 pub fn from_snapshot(snapshot: ThreadSnapshot) -> Self {
87 Self {
88 metadata: snapshot.metadata,
89 archive_listing: snapshot.archive_listing,
90 messages: snapshot.messages,
91 loaded_skills: snapshot.loaded_skills,
92 }
93 }
94
95 pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
96 self.messages = messages;
97 self
98 }
99
100 pub fn with_loaded_skills(mut self, loaded_skills: Vec<String>) -> Self {
101 self.loaded_skills = loaded_skills;
102 self
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum SessionQueryScope {
108 CurrentWorkspace(PathBuf),
109 All,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum ArchivedSessionIntent {
114 ResumeInPlace,
115 ForkNewArchive {
116 custom_suffix: Option<String>,
117 summarize: bool,
118 },
119}
120
121#[derive(Debug, Clone)]
122pub struct PreparedArchivedSession {
123 pub source: SessionListing,
124 pub workspace: PathBuf,
125 pub bootstrap: ThreadBootstrap,
126 pub thread_id: String,
127 pub archive: SessionArchive,
128}
129
130#[derive(Default)]
131struct ThreadEventStore {
132 capacity: usize,
133 next_sequence: u64,
134 events: VecDeque<ThreadEventRecord>,
135}
136
137impl ThreadEventStore {
138 fn with_capacity(capacity: usize) -> Self {
139 Self {
140 capacity: capacity.max(1),
141 ..Self::default()
142 }
143 }
144
145 fn push(
146 &mut self,
147 thread_id: &ThreadId,
148 submission_id: Option<SubmissionId>,
149 turn_id: Option<String>,
150 event: ThreadEvent,
151 ) {
152 let record = ThreadEventRecord {
153 sequence: self.next_sequence,
154 thread_id: thread_id.clone(),
155 submission_id,
156 turn_id,
157 event,
158 };
159 self.next_sequence = self.next_sequence.saturating_add(1);
160
161 if self.events.len() >= self.capacity {
162 self.events.pop_front();
163 }
164 self.events.push_back(record);
165 }
166
167 fn snapshot(&self) -> Vec<ThreadEventRecord> {
168 self.events.iter().cloned().collect()
169 }
170}
171
172struct ThreadSessionState {
173 thread_id: ThreadId,
174 metadata: Option<SessionArchiveMetadata>,
175 archive_listing: Option<SessionListing>,
176 messages: Vec<Message>,
177 loaded_skills: Vec<String>,
178 turn_in_flight: bool,
179}
180
181impl ThreadSessionState {
182 fn snapshot(&self) -> ThreadSnapshot {
183 ThreadSnapshot {
184 thread_id: self.thread_id.clone(),
185 metadata: self.metadata.clone(),
186 archive_listing: self.archive_listing.clone(),
187 messages: self.messages.clone(),
188 loaded_skills: self.loaded_skills.clone(),
189 turn_in_flight: self.turn_in_flight,
190 }
191 }
192}
193
194#[derive(Clone)]
195pub struct ThreadRuntimeHandle {
196 inner: Arc<ThreadRuntimeInner>,
197}
198
199struct ThreadRuntimeInner {
200 session: Mutex<ThreadSessionState>,
201 event_store: Mutex<ThreadEventStore>,
202}
203
204impl ThreadRuntimeHandle {
205 fn new(thread_id: ThreadId, bootstrap: ThreadBootstrap, event_capacity: usize) -> Self {
206 let session = ThreadSessionState {
207 thread_id,
208 metadata: bootstrap.metadata,
209 archive_listing: bootstrap.archive_listing,
210 messages: bootstrap.messages,
211 loaded_skills: bootstrap.loaded_skills,
212 turn_in_flight: false,
213 };
214
215 Self {
216 inner: Arc::new(ThreadRuntimeInner {
217 session: Mutex::new(session),
218 event_store: Mutex::new(ThreadEventStore::with_capacity(event_capacity)),
219 }),
220 }
221 }
222
223 pub fn thread_id(&self) -> ThreadId {
224 self.inner.session.lock().thread_id.clone()
225 }
226
227 pub fn snapshot(&self) -> ThreadSnapshot {
228 self.inner.session.lock().snapshot()
229 }
230
231 pub fn metadata(&self) -> Option<SessionArchiveMetadata> {
232 self.inner.session.lock().metadata.clone()
233 }
234
235 pub fn replace_metadata(&self, metadata: Option<SessionArchiveMetadata>) {
236 self.inner.session.lock().metadata = metadata;
237 }
238
239 pub fn archive_listing(&self) -> Option<SessionListing> {
240 self.inner.session.lock().archive_listing.clone()
241 }
242
243 pub fn messages(&self) -> Vec<Message> {
244 self.inner.session.lock().messages.clone()
245 }
246
247 pub fn replace_messages(&self, messages: Vec<Message>) {
248 self.inner.session.lock().messages = messages;
249 }
250
251 pub fn append_message(&self, message: Message) {
252 self.inner.session.lock().messages.push(message);
253 }
254
255 pub fn begin_turn(&self) -> Result<SubmissionId> {
256 let mut session = self.inner.session.lock();
257 if session.turn_in_flight {
258 return Err(anyhow!(
259 "thread '{}' already has an in-flight turn",
260 session.thread_id
261 ));
262 }
263
264 session.turn_in_flight = true;
265 Ok(SubmissionId::generate())
266 }
267
268 pub fn finish_turn(&self) {
269 self.inner.session.lock().turn_in_flight = false;
270 }
271
272 pub fn record_event(
273 &self,
274 submission_id: Option<SubmissionId>,
275 turn_id: Option<String>,
276 event: ThreadEvent,
277 ) {
278 let thread_id = self.thread_id();
279 self.inner
280 .event_store
281 .lock()
282 .push(&thread_id, submission_id, turn_id, event);
283 }
284
285 pub fn replay_recent(&self) -> Vec<ThreadEventRecord> {
286 self.inner.event_store.lock().snapshot()
287 }
288
289 pub fn recent_events(&self) -> Vec<ThreadEvent> {
290 self.replay_recent()
291 .into_iter()
292 .map(|record| record.event)
293 .collect()
294 }
295}
296
297#[derive(Clone)]
298pub struct ThreadManager {
299 event_buffer_capacity: usize,
300}
301
302impl Default for ThreadManager {
303 fn default() -> Self {
304 Self::new()
305 }
306}
307
308impl ThreadManager {
309 pub fn new() -> Self {
310 Self {
311 event_buffer_capacity: DEFAULT_EVENT_BUFFER_CAPACITY,
312 }
313 }
314
315 pub fn with_event_buffer_capacity(event_buffer_capacity: usize) -> Self {
316 Self {
317 event_buffer_capacity: event_buffer_capacity.max(1),
318 }
319 }
320
321 pub fn start_thread_with_identifier(
322 &self,
323 identifier: impl Into<String>,
324 bootstrap: ThreadBootstrap,
325 ) -> ThreadRuntimeHandle {
326 ThreadRuntimeHandle::new(
327 ThreadId::new(identifier.into()),
328 bootstrap,
329 self.event_buffer_capacity,
330 )
331 }
332
333 pub async fn start_thread(
334 &self,
335 workspace_label: &str,
336 custom_suffix: Option<String>,
337 bootstrap: ThreadBootstrap,
338 ) -> Result<ThreadRuntimeHandle> {
339 let identifier = reserve_session_archive_identifier(workspace_label, custom_suffix).await?;
340 Ok(self.start_thread_with_identifier(identifier, bootstrap))
341 }
342
343 pub async fn resume_thread(&self, identifier: &str) -> Result<Option<ThreadRuntimeHandle>> {
344 let listing = find_session_by_identifier(identifier).await?;
345 Ok(listing.map(|listing| {
346 self.start_thread_with_identifier(
347 listing.identifier(),
348 ThreadBootstrap::from_listing(listing),
349 )
350 }))
351 }
352}
353
354pub async fn list_recent_sessions_in_scope(
355 limit: usize,
356 scope: &SessionQueryScope,
357) -> Result<Vec<SessionListing>> {
358 let mut listings = list_recent_sessions(limit.saturating_mul(4).max(limit)).await?;
359 if let SessionQueryScope::CurrentWorkspace(workspace) = scope {
360 listings.retain(|listing| session_listing_matches_workspace(listing, workspace));
361 }
362 listings.truncate(limit);
363 Ok(listings)
364}
365
366pub async fn prepare_archived_session(
367 source: SessionListing,
368 workspace: PathBuf,
369 metadata: SessionArchiveMetadata,
370 intent: ArchivedSessionIntent,
371 reserved_identifier: Option<String>,
372) -> Result<PreparedArchivedSession> {
373 let mut metadata =
374 preserve_prompt_cache_lineage_if_compatible(metadata, &source.snapshot.metadata);
375 metadata.continuation_metadata = source.snapshot.metadata.continuation_metadata.clone();
376 let mut bootstrap = ThreadBootstrap::from_listing(source.clone());
377 bootstrap.metadata = Some(metadata.clone());
378
379 let thread_id = match &intent {
380 ArchivedSessionIntent::ResumeInPlace => source.identifier(),
381 ArchivedSessionIntent::ForkNewArchive { custom_suffix, .. } => {
382 if let Some(identifier) = reserved_identifier {
383 identifier
384 } else {
385 reserve_session_archive_identifier(&metadata.workspace_label, custom_suffix.clone())
386 .await?
387 }
388 }
389 };
390
391 if let ArchivedSessionIntent::ForkNewArchive { summarize, .. } = &intent {
392 metadata.parent_session_id = Some(source.identifier());
393 metadata.fork_mode = Some(if *summarize {
394 SessionForkMode::Summarized
395 } else {
396 SessionForkMode::FullCopy
397 });
398 bootstrap.metadata = Some(metadata.clone());
399 }
400
401 let archive = match intent {
402 ArchivedSessionIntent::ResumeInPlace => {
403 SessionArchive::resume_from_listing(&source, metadata)
404 }
405 ArchivedSessionIntent::ForkNewArchive { .. } => {
406 SessionArchive::new_with_identifier(metadata, thread_id.clone()).await?
407 }
408 };
409
410 Ok(PreparedArchivedSession {
411 source,
412 workspace,
413 bootstrap,
414 thread_id,
415 archive,
416 })
417}
418
419fn preserve_prompt_cache_lineage_if_compatible(
420 mut metadata: SessionArchiveMetadata,
421 source: &SessionArchiveMetadata,
422) -> SessionArchiveMetadata {
423 let is_compatible = metadata.workspace_path == source.workspace_path
424 && metadata.provider == source.provider
425 && metadata.model == source.model;
426 if is_compatible && let Some(lineage_id) = source.prompt_cache_lineage_id.as_ref() {
427 metadata.prompt_cache_lineage_id = Some(lineage_id.clone());
428 }
429 metadata
430}
431
432pub fn messages_from_session_listing(listing: &SessionListing) -> Vec<Message> {
433 if let Some(progress) = &listing.snapshot.progress
434 && !progress.recent_messages.is_empty()
435 {
436 progress.recent_messages.iter().map(Message::from).collect()
437 } else if !listing.snapshot.messages.is_empty() {
438 listing
439 .snapshot
440 .messages
441 .iter()
442 .map(Message::from)
443 .collect()
444 } else {
445 Vec::new()
446 }
447}
448
449pub fn loaded_skills_from_session_listing(listing: &SessionListing) -> Vec<String> {
450 listing
451 .snapshot
452 .progress
453 .as_ref()
454 .map(|progress| progress.loaded_skills.clone())
455 .filter(|skills| !skills.is_empty())
456 .unwrap_or_else(|| listing.snapshot.metadata.loaded_skills.clone())
457}
458
459pub fn build_thread_archive_metadata(
460 workspace: &Path,
461 model: &str,
462 provider: &str,
463 theme: &str,
464 reasoning_effort: &str,
465) -> SessionArchiveMetadata {
466 let workspace_label = workspace
467 .file_name()
468 .and_then(|value| value.to_str())
469 .unwrap_or("workspace");
470
471 SessionArchiveMetadata::new(
472 workspace_label,
473 workspace.to_string_lossy().to_string(),
474 model,
475 provider,
476 theme,
477 reasoning_effort,
478 )
479 .ensure_prompt_cache_lineage_id()
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::exec::events::{ThreadEvent, ThreadStartedEvent};
486 use crate::llm::provider::MessageRole;
487 use crate::utils::session_archive::{
488 SessionArchiveMetadata, SessionMessage, SessionProgress, SessionSnapshot,
489 clear_sessions_dir_override_for_tests, override_sessions_dir_for_tests,
490 };
491 use chrono::Utc;
492 use std::sync::{LazyLock, Mutex};
493 use tempfile::TempDir;
494
495 static SESSION_DIR_TEST_GUARD: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
496
497 #[test]
498 fn event_store_evicts_old_records() {
499 let manager = ThreadManager::with_event_buffer_capacity(2);
500 let handle = manager.start_thread_with_identifier("thread-1", ThreadBootstrap::new(None));
501
502 handle.record_event(
503 None,
504 None,
505 ThreadEvent::ThreadStarted(ThreadStartedEvent {
506 thread_id: "thread-1".to_string(),
507 }),
508 );
509 handle.record_event(
510 None,
511 Some("turn-1".to_string()),
512 ThreadEvent::ThreadStarted(ThreadStartedEvent {
513 thread_id: "thread-1-turn-1".to_string(),
514 }),
515 );
516 handle.record_event(
517 None,
518 Some("turn-2".to_string()),
519 ThreadEvent::ThreadStarted(ThreadStartedEvent {
520 thread_id: "thread-1-turn-2".to_string(),
521 }),
522 );
523
524 let records = handle.replay_recent();
525 assert_eq!(records.len(), 2);
526 assert_eq!(records[0].sequence, 1);
527 assert_eq!(records[1].sequence, 2);
528 }
529
530 #[test]
531 fn start_thread_with_identifier_preserves_message_history() {
532 let manager = ThreadManager::new();
533 let bootstrap = ThreadBootstrap::new(None)
534 .with_messages(vec![Message::user("hello".to_string())])
535 .with_loaded_skills(vec!["repo-skill".to_string()]);
536 let handle = manager.start_thread_with_identifier("thread-123", bootstrap);
537
538 assert_eq!(handle.thread_id().as_str(), "thread-123");
539 let snapshot = handle.snapshot();
540 assert_eq!(snapshot.messages.len(), 1);
541 assert_eq!(snapshot.loaded_skills, vec!["repo-skill".to_string()]);
542 }
543
544 #[test]
545 fn submit_enforces_single_in_flight_turn() {
546 let manager = ThreadManager::new();
547 let handle = manager.start_thread_with_identifier("thread-123", ThreadBootstrap::new(None));
548
549 let _first = handle.begin_turn().expect("first turn");
550 let err = handle.begin_turn().expect_err("second turn should fail");
551 assert!(err.to_string().contains("in-flight turn"));
552 handle.finish_turn();
553 handle.begin_turn().expect("turn after finish");
554 }
555
556 #[test]
557 #[serial_test::serial(session_dir_override)]
558 fn list_recent_sessions_in_scope_filters_by_workspace() {
559 let _guard = SESSION_DIR_TEST_GUARD
560 .lock()
561 .expect("session dir test guard");
562 let tmp = TempDir::new().expect("temp dir");
563 override_sessions_dir_for_tests(tmp.path());
564
565 let listing = SessionListing {
566 path: tmp.path().join("session-alpha.json"),
567 snapshot: SessionSnapshot {
568 metadata: SessionArchiveMetadata::new(
569 "ws",
570 tmp.path().join("workspace").display().to_string(),
571 "model",
572 "provider",
573 "theme",
574 "medium",
575 ),
576 started_at: Utc::now(),
577 ended_at: Utc::now(),
578 total_messages: 1,
579 distinct_tools: Vec::new(),
580 transcript: Vec::new(),
581 messages: vec![SessionMessage::new(MessageRole::User, "hello")],
582 progress: None,
583 error_logs: Vec::new(),
584 },
585 };
586 std::fs::write(
587 &listing.path,
588 serde_json::to_string(&listing.snapshot).expect("serialize snapshot"),
589 )
590 .expect("write listing");
591
592 let runtime = tokio::runtime::Runtime::new().expect("runtime");
593 let filtered = runtime
594 .block_on(list_recent_sessions_in_scope(
595 5,
596 &SessionQueryScope::CurrentWorkspace(tmp.path().join("workspace")),
597 ))
598 .expect("filter by workspace");
599 let all = runtime
600 .block_on(list_recent_sessions_in_scope(5, &SessionQueryScope::All))
601 .expect("list all");
602
603 clear_sessions_dir_override_for_tests();
604
605 assert_eq!(filtered.len(), 1);
606 assert_eq!(all.len(), 1);
607 }
608
609 #[test]
610 #[serial_test::serial(session_dir_override)]
611 fn prepare_archived_session_resume_reuses_source_identifier_and_archive() {
612 let _guard = SESSION_DIR_TEST_GUARD
613 .lock()
614 .expect("session dir test guard");
615 let tmp = TempDir::new().expect("temp dir");
616 override_sessions_dir_for_tests(tmp.path());
617
618 let listing = SessionListing {
619 path: tmp.path().join("session-source.json"),
620 snapshot: SessionSnapshot {
621 metadata: SessionArchiveMetadata::new(
622 "ws",
623 tmp.path().join("workspace").display().to_string(),
624 "old-model",
625 "old-provider",
626 "old-theme",
627 "medium",
628 ),
629 started_at: Utc::now(),
630 ended_at: Utc::now(),
631 total_messages: 2,
632 distinct_tools: vec!["tool_a".to_string()],
633 transcript: Vec::new(),
634 messages: vec![SessionMessage::new(MessageRole::User, "hello")],
635 progress: Some(Box::new(SessionProgress {
636 turn_number: 1,
637 recent_messages: vec![SessionMessage::new(MessageRole::Assistant, "recent")],
638 tool_summaries: Vec::new(),
639 token_usage: None,
640 max_context_tokens: None,
641 loaded_skills: vec!["skill_a".to_string()],
642 })),
643 error_logs: Vec::new(),
644 },
645 };
646
647 let runtime = tokio::runtime::Runtime::new().expect("runtime");
648 let prepared = runtime
649 .block_on(prepare_archived_session(
650 listing.clone(),
651 tmp.path().join("workspace"),
652 SessionArchiveMetadata::new(
653 "ws",
654 tmp.path().join("workspace").display().to_string(),
655 "new-model",
656 "new-provider",
657 "new-theme",
658 "high",
659 ),
660 ArchivedSessionIntent::ResumeInPlace,
661 Some("should-not-be-used".to_string()),
662 ))
663 .expect("prepare resume");
664
665 clear_sessions_dir_override_for_tests();
666
667 assert_eq!(prepared.thread_id, listing.identifier());
668 assert_eq!(prepared.archive.path(), listing.path.as_path());
669 assert_eq!(prepared.bootstrap.messages[0].content.as_text(), "recent");
670 assert_eq!(
671 prepared.bootstrap.loaded_skills,
672 vec!["skill_a".to_string()]
673 );
674 assert_eq!(
675 prepared
676 .bootstrap
677 .metadata
678 .as_ref()
679 .expect("metadata")
680 .model,
681 "new-model"
682 );
683 }
684
685 #[test]
686 #[serial_test::serial(session_dir_override)]
687 fn prepare_archived_session_fork_uses_new_identifier_and_preserves_history() {
688 let _guard = SESSION_DIR_TEST_GUARD
689 .lock()
690 .expect("session dir test guard");
691 let tmp = TempDir::new().expect("temp dir");
692 override_sessions_dir_for_tests(tmp.path());
693
694 let listing = SessionListing {
695 path: tmp.path().join("session-source.json"),
696 snapshot: SessionSnapshot {
697 metadata: SessionArchiveMetadata::new(
698 "ws",
699 tmp.path().join("workspace").display().to_string(),
700 "model",
701 "provider",
702 "theme",
703 "medium",
704 ),
705 started_at: Utc::now(),
706 ended_at: Utc::now(),
707 total_messages: 1,
708 distinct_tools: Vec::new(),
709 transcript: Vec::new(),
710 messages: vec![SessionMessage::new(MessageRole::User, "hello")],
711 progress: None,
712 error_logs: Vec::new(),
713 },
714 };
715
716 let runtime = tokio::runtime::Runtime::new().expect("runtime");
717 let prepared = runtime
718 .block_on(prepare_archived_session(
719 listing.clone(),
720 tmp.path().join("workspace"),
721 SessionArchiveMetadata::new(
722 "ws",
723 tmp.path().join("workspace").display().to_string(),
724 "model",
725 "provider",
726 "theme",
727 "medium",
728 ),
729 ArchivedSessionIntent::ForkNewArchive {
730 custom_suffix: Some("branch".to_string()),
731 summarize: false,
732 },
733 Some("session-forked".to_string()),
734 ))
735 .expect("prepare fork");
736
737 clear_sessions_dir_override_for_tests();
738
739 assert_eq!(prepared.thread_id, "session-forked");
740 assert_ne!(prepared.archive.path(), listing.path.as_path());
741 assert!(
742 prepared
743 .archive
744 .path()
745 .ends_with(Path::new("session-forked.json"))
746 );
747 assert_eq!(prepared.bootstrap.messages[0].content.as_text(), "hello");
748 assert_eq!(
749 prepared
750 .bootstrap
751 .metadata
752 .as_ref()
753 .and_then(|metadata| metadata.parent_session_id.as_deref()),
754 Some("session-source")
755 );
756 assert_eq!(
757 prepared
758 .bootstrap
759 .metadata
760 .as_ref()
761 .and_then(|metadata| metadata.fork_mode),
762 Some(SessionForkMode::FullCopy)
763 );
764 }
765
766 #[test]
767 #[serial_test::serial(session_dir_override)]
768 fn prepare_archived_session_preserves_prompt_cache_lineage_when_compatible() {
769 let _guard = SESSION_DIR_TEST_GUARD
770 .lock()
771 .expect("session dir test guard");
772 let tmp = TempDir::new().expect("temp dir");
773 override_sessions_dir_for_tests(tmp.path());
774
775 let listing = SessionListing {
776 path: tmp.path().join("session-source.json"),
777 snapshot: SessionSnapshot {
778 metadata: SessionArchiveMetadata::new(
779 "ws",
780 tmp.path().join("workspace").display().to_string(),
781 "model",
782 "provider",
783 "theme",
784 "medium",
785 )
786 .with_prompt_cache_lineage_id("lineage-source"),
787 started_at: Utc::now(),
788 ended_at: Utc::now(),
789 total_messages: 1,
790 distinct_tools: Vec::new(),
791 transcript: Vec::new(),
792 messages: vec![SessionMessage::new(MessageRole::User, "hello")],
793 progress: None,
794 error_logs: Vec::new(),
795 },
796 };
797
798 let runtime = tokio::runtime::Runtime::new().expect("runtime");
799 let prepared = runtime
800 .block_on(prepare_archived_session(
801 listing,
802 tmp.path().join("workspace"),
803 SessionArchiveMetadata::new(
804 "ws",
805 tmp.path().join("workspace").display().to_string(),
806 "model",
807 "provider",
808 "theme",
809 "medium",
810 )
811 .with_prompt_cache_lineage_id("lineage-new"),
812 ArchivedSessionIntent::ResumeInPlace,
813 None,
814 ))
815 .expect("prepare resume");
816
817 clear_sessions_dir_override_for_tests();
818
819 assert_eq!(
820 prepared
821 .bootstrap
822 .metadata
823 .as_ref()
824 .and_then(|metadata| metadata.prompt_cache_lineage_id.as_deref()),
825 Some("lineage-source")
826 );
827 }
828
829 #[test]
830 #[serial_test::serial(session_dir_override)]
831 fn prepare_archived_session_resets_prompt_cache_lineage_on_model_change() {
832 let _guard = SESSION_DIR_TEST_GUARD
833 .lock()
834 .expect("session dir test guard");
835 let tmp = TempDir::new().expect("temp dir");
836 override_sessions_dir_for_tests(tmp.path());
837
838 let listing = SessionListing {
839 path: tmp.path().join("session-source.json"),
840 snapshot: SessionSnapshot {
841 metadata: SessionArchiveMetadata::new(
842 "ws",
843 tmp.path().join("workspace").display().to_string(),
844 "model-a",
845 "provider",
846 "theme",
847 "medium",
848 )
849 .with_prompt_cache_lineage_id("lineage-source"),
850 started_at: Utc::now(),
851 ended_at: Utc::now(),
852 total_messages: 1,
853 distinct_tools: Vec::new(),
854 transcript: Vec::new(),
855 messages: vec![SessionMessage::new(MessageRole::User, "hello")],
856 progress: None,
857 error_logs: Vec::new(),
858 },
859 };
860
861 let runtime = tokio::runtime::Runtime::new().expect("runtime");
862 let prepared = runtime
863 .block_on(prepare_archived_session(
864 listing,
865 tmp.path().join("workspace"),
866 SessionArchiveMetadata::new(
867 "ws",
868 tmp.path().join("workspace").display().to_string(),
869 "model-b",
870 "provider",
871 "theme",
872 "medium",
873 )
874 .with_prompt_cache_lineage_id("lineage-new"),
875 ArchivedSessionIntent::ResumeInPlace,
876 None,
877 ))
878 .expect("prepare resume");
879
880 clear_sessions_dir_override_for_tests();
881
882 assert_eq!(
883 prepared
884 .bootstrap
885 .metadata
886 .as_ref()
887 .and_then(|metadata| metadata.prompt_cache_lineage_id.as_deref()),
888 Some("lineage-new")
889 );
890 }
891
892 #[test]
893 fn messages_from_session_listing_preserves_assistant_phases_from_progress() {
894 let listing = SessionListing {
895 path: PathBuf::from("session.json"),
896 snapshot: SessionSnapshot {
897 metadata: SessionArchiveMetadata::new(
898 "ws", "/tmp/ws", "gpt-5.4", "openai", "theme", "medium",
899 ),
900 started_at: Utc::now(),
901 ended_at: Utc::now(),
902 total_messages: 2,
903 distinct_tools: Vec::new(),
904 transcript: Vec::new(),
905 messages: Vec::new(),
906 progress: Some(Box::new(SessionProgress {
907 turn_number: 2,
908 recent_messages: vec![
909 SessionMessage::from(
910 &Message::assistant("Working".to_string())
911 .with_phase(Some(crate::llm::provider::AssistantPhase::Commentary)),
912 ),
913 SessionMessage::from(
914 &Message::assistant("Done".to_string()).with_phase(Some(
915 crate::llm::provider::AssistantPhase::FinalAnswer,
916 )),
917 ),
918 ],
919 tool_summaries: Vec::new(),
920 token_usage: None,
921 max_context_tokens: None,
922 loaded_skills: Vec::new(),
923 })),
924 error_logs: Vec::new(),
925 },
926 };
927
928 let messages = messages_from_session_listing(&listing);
929 assert_eq!(
930 messages
931 .iter()
932 .map(|message| message.phase)
933 .collect::<Vec<_>>(),
934 vec![
935 Some(crate::llm::provider::AssistantPhase::Commentary),
936 Some(crate::llm::provider::AssistantPhase::FinalAnswer),
937 ]
938 );
939 }
940}