1use crate::error::MemoryError;
5use crate::store::SqliteStore;
6use crate::types::ConversationId;
7use zeph_db::ActiveDialect;
8#[allow(unused_imports)]
9use zeph_db::sql;
10
11pub struct AcpSessionEvent {
12 pub event_type: String,
13 pub payload: String,
14 pub created_at: String,
15}
16
17pub struct AcpSessionInfo {
18 pub id: String,
19 pub title: Option<String>,
20 pub created_at: String,
21 pub updated_at: String,
22 pub message_count: i64,
23}
24
25pub struct AcpSessionConfigSnapshot {
29 pub current_model: String,
30 pub temperature_preset: String,
31 pub thinking_enabled: bool,
32 pub auto_approve_level: String,
33}
34
35impl SqliteStore {
36 pub async fn create_acp_session(&self, session_id: &str) -> Result<(), MemoryError> {
42 let sql = zeph_db::rewrite_placeholders(&format!(
43 "{} INTO acp_sessions (id) VALUES (?){}",
44 <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
45 <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
46 ));
47 zeph_db::query(sqlx::AssertSqlSafe(sql))
48 .bind(session_id)
49 .execute(&self.pool)
50 .await?;
51 Ok(())
52 }
53
54 pub async fn save_acp_event(
60 &self,
61 session_id: &str,
62 event_type: &str,
63 payload: &str,
64 ) -> Result<(), MemoryError> {
65 zeph_db::query(sql!(
66 "INSERT INTO acp_session_events (session_id, event_type, payload) VALUES (?, ?, ?)"
67 ))
68 .bind(session_id)
69 .bind(event_type)
70 .bind(payload)
71 .execute(&self.pool)
72 .await?;
73 Ok(())
74 }
75
76 pub async fn load_acp_events(
82 &self,
83 session_id: &str,
84 ) -> Result<Vec<AcpSessionEvent>, MemoryError> {
85 let rows = zeph_db::query_as::<_, (String, String, String)>(
86 sql!("SELECT event_type, payload, created_at FROM acp_session_events WHERE session_id = ? ORDER BY id"),
87 )
88 .bind(session_id)
89 .fetch_all(&self.pool)
90 .await?;
91
92 Ok(rows
93 .into_iter()
94 .map(|(event_type, payload, created_at)| AcpSessionEvent {
95 event_type,
96 payload,
97 created_at,
98 })
99 .collect())
100 }
101
102 pub async fn delete_acp_session_checked(&self, session_id: &str) -> Result<bool, MemoryError> {
111 let result = zeph_db::query(sql!("DELETE FROM acp_sessions WHERE id = ?"))
112 .bind(session_id)
113 .execute(&self.pool)
114 .await?;
115 Ok(result.rows_affected() > 0)
116 }
117
118 pub async fn list_acp_sessions(
127 &self,
128 limit: usize,
129 ) -> Result<Vec<AcpSessionInfo>, MemoryError> {
130 #[allow(clippy::cast_possible_wrap)]
132 let sql_limit: i64 = if limit == 0 { -1 } else { limit as i64 };
133 let created_at_sel =
141 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
142 let updated_at_sel =
143 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
144 let raw = format!(
145 "SELECT s.id, s.title, s.{created_at_sel}, s.{updated_at_sel}, \
146 s.event_count AS message_count \
147 FROM acp_sessions s \
148 ORDER BY s.updated_at DESC \
149 LIMIT ?"
150 );
151 let query_sql = zeph_db::rewrite_placeholders(&raw);
152 let rows = zeph_db::query_as::<_, (String, Option<String>, String, String, i64)>(
153 sqlx::AssertSqlSafe(query_sql),
154 )
155 .bind(sql_limit)
156 .fetch_all(&self.pool)
157 .await?;
158
159 Ok(rows
160 .into_iter()
161 .map(
162 |(id, title, created_at, updated_at, message_count)| AcpSessionInfo {
163 id,
164 title,
165 created_at,
166 updated_at,
167 message_count,
168 },
169 )
170 .collect())
171 }
172
173 pub async fn get_acp_session_info(
181 &self,
182 session_id: &str,
183 ) -> Result<Option<AcpSessionInfo>, MemoryError> {
184 let created_at_sel =
188 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
189 let updated_at_sel =
190 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
191 let raw = format!(
192 "SELECT s.id, s.title, s.{created_at_sel}, s.{updated_at_sel}, \
193 s.event_count AS message_count \
194 FROM acp_sessions s \
195 WHERE s.id = ?"
196 );
197 let query_sql = zeph_db::rewrite_placeholders(&raw);
198 let row = zeph_db::query_as::<_, (String, Option<String>, String, String, i64)>(
199 sqlx::AssertSqlSafe(query_sql),
200 )
201 .bind(session_id)
202 .fetch_optional(&self.pool)
203 .await?;
204
205 Ok(row.map(
206 |(id, title, created_at, updated_at, message_count)| AcpSessionInfo {
207 id,
208 title,
209 created_at,
210 updated_at,
211 message_count,
212 },
213 ))
214 }
215
216 pub async fn import_acp_events(
225 &self,
226 session_id: &str,
227 events: &[(&str, &str)],
228 ) -> Result<(), MemoryError> {
229 let mut tx = self.pool.begin().await?;
230 for (event_type, payload) in events {
231 zeph_db::query(sql!(
232 "INSERT INTO acp_session_events (session_id, event_type, payload) VALUES (?, ?, ?)"
233 ))
234 .bind(session_id)
235 .bind(event_type)
236 .bind(payload)
237 .execute(&mut *tx)
238 .await?;
239 }
240 tx.commit().await?;
241 Ok(())
242 }
243
244 pub async fn update_session_title(
250 &self,
251 session_id: &str,
252 title: &str,
253 ) -> Result<(), MemoryError> {
254 zeph_db::query(sql!("UPDATE acp_sessions SET title = ? WHERE id = ?"))
255 .bind(title)
256 .bind(session_id)
257 .execute(&self.pool)
258 .await?;
259 Ok(())
260 }
261
262 pub async fn update_session_title_checked(
271 &self,
272 session_id: &str,
273 title: &str,
274 ) -> Result<bool, MemoryError> {
275 let result = zeph_db::query(sql!("UPDATE acp_sessions SET title = ? WHERE id = ?"))
276 .bind(title)
277 .bind(session_id)
278 .execute(&self.pool)
279 .await?;
280 Ok(result.rows_affected() > 0)
281 }
282
283 pub async fn save_session_config(
292 &self,
293 session_id: &str,
294 snapshot: &AcpSessionConfigSnapshot,
295 ) -> Result<(), MemoryError> {
296 zeph_db::query(sql!(
297 "UPDATE acp_sessions SET current_model = ?, temperature_preset = ?, \
298 thinking_enabled = ?, auto_approve_level = ? WHERE id = ?"
299 ))
300 .bind(&snapshot.current_model)
301 .bind(&snapshot.temperature_preset)
302 .bind(snapshot.thinking_enabled)
303 .bind(&snapshot.auto_approve_level)
304 .bind(session_id)
305 .execute(&self.pool)
306 .await?;
307 Ok(())
308 }
309
310 pub async fn get_session_config(
320 &self,
321 session_id: &str,
322 ) -> Result<Option<AcpSessionConfigSnapshot>, MemoryError> {
323 let row = zeph_db::query_as::<
324 _,
325 (Option<String>, Option<String>, Option<bool>, Option<String>),
326 >(sql!(
327 "SELECT current_model, temperature_preset, thinking_enabled, auto_approve_level \
328 FROM acp_sessions WHERE id = ?"
329 ))
330 .bind(session_id)
331 .fetch_optional(&self.pool)
332 .await?;
333
334 Ok(row.and_then(
335 |(current_model, temperature_preset, thinking_enabled, auto_approve_level)| {
336 Some(AcpSessionConfigSnapshot {
337 current_model: current_model?,
338 temperature_preset: temperature_preset?,
339 thinking_enabled: thinking_enabled?,
340 auto_approve_level: auto_approve_level?,
341 })
342 },
343 ))
344 }
345
346 pub async fn acp_session_exists(&self, session_id: &str) -> Result<bool, MemoryError> {
352 let count: i64 =
353 zeph_db::query_scalar(sql!("SELECT COUNT(*) FROM acp_sessions WHERE id = ?"))
354 .bind(session_id)
355 .fetch_one(&self.pool)
356 .await?;
357 Ok(count > 0)
358 }
359
360 pub async fn create_acp_session_with_conversation(
366 &self,
367 session_id: &str,
368 conversation_id: ConversationId,
369 ) -> Result<(), MemoryError> {
370 let sql = zeph_db::rewrite_placeholders(&format!(
371 "{} INTO acp_sessions (id, conversation_id) VALUES (?, ?){}",
372 <ActiveDialect as zeph_db::dialect::Dialect>::INSERT_IGNORE,
373 <ActiveDialect as zeph_db::dialect::Dialect>::CONFLICT_NOTHING,
374 ));
375 zeph_db::query(sqlx::AssertSqlSafe(sql))
376 .bind(session_id)
377 .bind(conversation_id)
378 .execute(&self.pool)
379 .await?;
380 Ok(())
381 }
382
383 pub async fn get_acp_session_conversation_id(
391 &self,
392 session_id: &str,
393 ) -> Result<Option<ConversationId>, MemoryError> {
394 let row: Option<(Option<ConversationId>,)> = zeph_db::query_as(sql!(
395 "SELECT conversation_id FROM acp_sessions WHERE id = ?"
396 ))
397 .bind(session_id)
398 .fetch_optional(&self.pool)
399 .await?;
400 Ok(row.and_then(|(cid,)| cid))
401 }
402
403 pub async fn set_acp_session_conversation_id(
409 &self,
410 session_id: &str,
411 conversation_id: ConversationId,
412 ) -> Result<(), MemoryError> {
413 zeph_db::query(sql!(
414 "UPDATE acp_sessions SET conversation_id = ? WHERE id = ?"
415 ))
416 .bind(conversation_id)
417 .bind(session_id)
418 .execute(&self.pool)
419 .await?;
420 Ok(())
421 }
422
423 pub async fn copy_conversation(
436 &self,
437 source: ConversationId,
438 target: ConversationId,
439 ) -> Result<(), MemoryError> {
440 let mut tx = self.pool.begin().await?;
441
442 zeph_db::query(sql!(
446 "INSERT INTO messages \
447 (conversation_id, role, content, parts, visibility, compacted_at, deleted_at) \
448 SELECT ?, role, content, parts, visibility, compacted_at, deleted_at \
449 FROM messages WHERE conversation_id = ? ORDER BY id"
450 ))
451 .bind(target)
452 .bind(source)
453 .execute(&mut *tx)
454 .await?;
455
456 tx.commit().await?;
461 Ok(())
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 async fn make_store() -> SqliteStore {
470 SqliteStore::new(":memory:")
471 .await
472 .expect("SqliteStore::new")
473 }
474
475 async fn bump_event_count(store: &SqliteStore, session_id: &str, event_count: i64) {
482 let stmt = zeph_db::rewrite_placeholders(&format!(
483 "UPDATE acp_sessions SET event_count = ?, updated_at = {} WHERE id = ?",
484 <ActiveDialect as zeph_db::dialect::Dialect>::NOW,
485 ));
486 zeph_db::query(sqlx::AssertSqlSafe(stmt))
487 .bind(event_count)
488 .bind(session_id)
489 .execute(store.pool())
490 .await
491 .unwrap();
492 }
493
494 #[tokio::test]
495 async fn create_and_exists() {
496 let store = make_store().await;
497 store.create_acp_session("sess-1").await.unwrap();
498 assert!(store.acp_session_exists("sess-1").await.unwrap());
499 assert!(!store.acp_session_exists("sess-2").await.unwrap());
500 }
501
502 #[tokio::test]
503 async fn session_config_round_trips() {
504 let store = make_store().await;
505 store.create_acp_session("sess-1").await.unwrap();
506 let snapshot = AcpSessionConfigSnapshot {
507 current_model: "claude:opus".to_owned(),
508 temperature_preset: "creative".to_owned(),
509 thinking_enabled: true,
510 auto_approve_level: "auto-edit".to_owned(),
511 };
512 store
513 .save_session_config("sess-1", &snapshot)
514 .await
515 .unwrap();
516
517 let loaded = store
518 .get_session_config("sess-1")
519 .await
520 .unwrap()
521 .expect("snapshot must be present after save");
522 assert_eq!(loaded.current_model, "claude:opus");
523 assert_eq!(loaded.temperature_preset, "creative");
524 assert!(loaded.thinking_enabled);
525 assert_eq!(loaded.auto_approve_level, "auto-edit");
526 }
527
528 #[tokio::test]
529 async fn session_config_missing_snapshot_returns_none() {
530 let store = make_store().await;
531 store.create_acp_session("sess-1").await.unwrap();
532 assert!(store.get_session_config("sess-1").await.unwrap().is_none());
533 }
534
535 #[tokio::test]
536 async fn session_config_unknown_session_returns_none() {
537 let store = make_store().await;
538 assert!(store.get_session_config("no-such").await.unwrap().is_none());
539 }
540
541 #[tokio::test]
542 async fn save_and_load_events() {
543 let store = make_store().await;
544 store.create_acp_session("sess-1").await.unwrap();
545 store
546 .save_acp_event("sess-1", "user_message", "hello")
547 .await
548 .unwrap();
549 store
550 .save_acp_event("sess-1", "agent_message", "world")
551 .await
552 .unwrap();
553
554 let events = store.load_acp_events("sess-1").await.unwrap();
555 assert_eq!(events.len(), 2);
556 assert_eq!(events[0].event_type, "user_message");
557 assert_eq!(events[0].payload, "hello");
558 assert_eq!(events[1].event_type, "agent_message");
559 assert_eq!(events[1].payload, "world");
560 }
561
562 #[tokio::test]
563 async fn delete_cascades_events() {
564 let store = make_store().await;
565 store.create_acp_session("sess-1").await.unwrap();
566 store
567 .save_acp_event("sess-1", "user_message", "hello")
568 .await
569 .unwrap();
570 store.delete_acp_session_checked("sess-1").await.unwrap();
571
572 assert!(!store.acp_session_exists("sess-1").await.unwrap());
573 let events = store.load_acp_events("sess-1").await.unwrap();
574 assert!(events.is_empty());
575 }
576
577 #[tokio::test]
578 async fn load_events_empty_for_unknown() {
579 let store = make_store().await;
580 let events = store.load_acp_events("no-such").await.unwrap();
581 assert!(events.is_empty());
582 }
583
584 #[tokio::test]
585 async fn list_sessions_includes_title_and_message_count() {
586 let store = make_store().await;
587 store.create_acp_session("sess-b").await.unwrap();
588
589 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
592
593 store.create_acp_session("sess-a").await.unwrap();
594 bump_event_count(&store, "sess-a", 2).await;
595 store
596 .update_session_title("sess-a", "My Chat")
597 .await
598 .unwrap();
599
600 let sessions = store.list_acp_sessions(100).await.unwrap();
601 assert_eq!(sessions[0].id, "sess-a");
603 assert_eq!(sessions[0].title.as_deref(), Some("My Chat"));
604 assert_eq!(sessions[0].message_count, 2);
605
606 let b = sessions.iter().find(|s| s.id == "sess-b").unwrap();
608 assert!(b.title.is_none());
609 assert_eq!(b.message_count, 0);
610 }
611
612 #[tokio::test]
613 async fn list_sessions_respects_limit() {
614 let store = make_store().await;
615 for i in 0..5u8 {
616 store
617 .create_acp_session(&format!("sess-{i}"))
618 .await
619 .unwrap();
620 }
621 let sessions = store.list_acp_sessions(3).await.unwrap();
622 assert_eq!(sessions.len(), 3);
623 }
624
625 #[tokio::test]
626 async fn list_sessions_limit_one_boundary() {
627 let store = make_store().await;
628 for i in 0..3u8 {
629 store
630 .create_acp_session(&format!("sess-{i}"))
631 .await
632 .unwrap();
633 }
634 let sessions = store.list_acp_sessions(1).await.unwrap();
635 assert_eq!(sessions.len(), 1);
636 }
637
638 #[tokio::test]
639 async fn list_sessions_unlimited_when_zero() {
640 let store = make_store().await;
641 for i in 0..5u8 {
642 store
643 .create_acp_session(&format!("sess-{i}"))
644 .await
645 .unwrap();
646 }
647 let sessions = store.list_acp_sessions(0).await.unwrap();
648 assert_eq!(sessions.len(), 5);
649 }
650
651 #[tokio::test]
652 async fn get_acp_session_info_returns_none_for_missing() {
653 let store = make_store().await;
654 let info = store.get_acp_session_info("no-such").await.unwrap();
655 assert!(info.is_none());
656 }
657
658 #[tokio::test]
659 async fn get_acp_session_info_returns_data() {
660 let store = make_store().await;
661 store.create_acp_session("sess-x").await.unwrap();
662 bump_event_count(&store, "sess-x", 1).await;
663 store.update_session_title("sess-x", "Test").await.unwrap();
664
665 let info = store.get_acp_session_info("sess-x").await.unwrap().unwrap();
666 assert_eq!(info.id, "sess-x");
667 assert_eq!(info.title.as_deref(), Some("Test"));
668 assert_eq!(info.message_count, 1);
669 }
670
671 #[tokio::test]
672 async fn updated_at_trigger_fires_on_event_insert() {
673 let store = make_store().await;
674 store.create_acp_session("sess-t").await.unwrap();
675
676 let before = store
677 .get_acp_session_info("sess-t")
678 .await
679 .unwrap()
680 .unwrap()
681 .updated_at
682 .clone();
683
684 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
686
687 store
688 .save_acp_event("sess-t", "user", "ping")
689 .await
690 .unwrap();
691
692 let after = store
693 .get_acp_session_info("sess-t")
694 .await
695 .unwrap()
696 .unwrap()
697 .updated_at;
698
699 assert!(
700 after > before,
701 "updated_at should increase after event insert: before={before} after={after}"
702 );
703 }
704
705 #[tokio::test]
706 async fn create_session_with_conversation_and_retrieve() {
707 let store = make_store().await;
708 let cid = store.create_conversation().await.unwrap();
709 store
710 .create_acp_session_with_conversation("sess-1", cid)
711 .await
712 .unwrap();
713 let retrieved = store
714 .get_acp_session_conversation_id("sess-1")
715 .await
716 .unwrap();
717 assert_eq!(retrieved, Some(cid));
718 }
719
720 #[tokio::test]
721 async fn get_conversation_id_returns_none_for_legacy_session() {
722 let store = make_store().await;
723 store.create_acp_session("legacy").await.unwrap();
724 let cid = store
725 .get_acp_session_conversation_id("legacy")
726 .await
727 .unwrap();
728 assert!(cid.is_none());
729 }
730
731 #[tokio::test]
732 async fn get_conversation_id_returns_none_for_missing_session() {
733 let store = make_store().await;
734 let cid = store
735 .get_acp_session_conversation_id("no-such")
736 .await
737 .unwrap();
738 assert!(cid.is_none());
739 }
740
741 #[tokio::test]
742 async fn set_conversation_id_updates_existing_session() {
743 let store = make_store().await;
744 store.create_acp_session("sess-2").await.unwrap();
745 let cid = store.create_conversation().await.unwrap();
746 store
747 .set_acp_session_conversation_id("sess-2", cid)
748 .await
749 .unwrap();
750 let retrieved = store
751 .get_acp_session_conversation_id("sess-2")
752 .await
753 .unwrap();
754 assert_eq!(retrieved, Some(cid));
755 }
756
757 #[tokio::test]
758 async fn copy_conversation_copies_messages_in_order() {
759 use zeph_llm::provider::Role;
760 let store = make_store().await;
761 let src = store.create_conversation().await.unwrap();
762 store.save_message(src, "user", "hello").await.unwrap();
763 store.save_message(src, "assistant", "world").await.unwrap();
764
765 let dst = store.create_conversation().await.unwrap();
766 store.copy_conversation(src, dst).await.unwrap();
767
768 let msgs = store.load_history(dst, 100).await.unwrap();
769 assert_eq!(msgs.len(), 2);
770 assert_eq!(msgs[0].role, Role::User);
771 assert_eq!(msgs[0].content, "hello");
772 assert_eq!(msgs[1].role, Role::Assistant);
773 assert_eq!(msgs[1].content, "world");
774 }
775
776 #[tokio::test]
777 async fn copy_conversation_empty_source_is_noop() {
778 let store = make_store().await;
779 let src = store.create_conversation().await.unwrap();
780 let dst = store.create_conversation().await.unwrap();
781 store.copy_conversation(src, dst).await.unwrap();
782 let msgs = store.load_history(dst, 100).await.unwrap();
783 assert!(msgs.is_empty());
784 }
785
786 #[tokio::test]
787 async fn copy_conversation_does_not_copy_summaries() {
788 let store = make_store().await;
791 let src = store.create_conversation().await.unwrap();
792 store.save_message(src, "user", "hello").await.unwrap();
793 zeph_db::query(
795 sql!("INSERT INTO summaries (conversation_id, content, first_message_id, last_message_id, token_estimate) \
796 VALUES (?, 'summary text', 1, 1, 10)"),
797 )
798 .bind(src)
799 .execute(&store.pool)
800 .await
801 .unwrap();
802
803 let dst = store.create_conversation().await.unwrap();
804 store.copy_conversation(src, dst).await.unwrap();
805
806 let count: i64 = zeph_db::query_scalar(sql!(
807 "SELECT COUNT(*) FROM summaries WHERE conversation_id = ?"
808 ))
809 .bind(dst)
810 .fetch_one(&store.pool)
811 .await
812 .unwrap();
813 assert_eq!(
814 count, 0,
815 "summaries must not be copied to forked conversation"
816 );
817 }
818
819 #[tokio::test]
820 async fn concurrent_sessions_get_distinct_conversation_ids() {
821 let store = make_store().await;
822 let cid1 = store.create_conversation().await.unwrap();
823 let cid2 = store.create_conversation().await.unwrap();
824 store
825 .create_acp_session_with_conversation("sess-a", cid1)
826 .await
827 .unwrap();
828 store
829 .create_acp_session_with_conversation("sess-b", cid2)
830 .await
831 .unwrap();
832
833 let retrieved1 = store
834 .get_acp_session_conversation_id("sess-a")
835 .await
836 .unwrap();
837 let retrieved2 = store
838 .get_acp_session_conversation_id("sess-b")
839 .await
840 .unwrap();
841
842 assert!(retrieved1.is_some());
843 assert!(retrieved2.is_some());
844 assert_ne!(
845 retrieved1, retrieved2,
846 "concurrent sessions must get distinct conversation_ids"
847 );
848 }
849}