Skip to main content

zeph_memory/store/
acp_sessions.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
25/// Snapshot of per-session config fields (#5373), taken on graceful `session/close` so a later
26/// `session/resume` or `session/fork` can inherit these values instead of resetting to
27/// configured defaults.
28pub 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    /// Create a new ACP session record.
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the database write fails.
41    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    /// Persist a single ACP session event.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if the database write fails.
59    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    /// Load all events for an ACP session in insertion order.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if the database query fails.
81    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    /// Delete an ACP session only if it exists; returns `true` when a row was deleted.
103    ///
104    /// Eliminates the separate exists-check + delete TOCTOU race by relying on a
105    /// single DELETE statement and inspecting affected rows.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if the database write fails.
110    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    /// List ACP sessions ordered by last activity descending.
119    ///
120    /// Includes title, `updated_at`, and message count per session.
121    /// Pass `limit = 0` for unlimited results.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the database query fails.
126    pub async fn list_acp_sessions(
127        &self,
128        limit: usize,
129    ) -> Result<Vec<AcpSessionInfo>, MemoryError> {
130        // LIMIT -1 in SQLite means no limit; cast limit=0 sentinel to -1.
131        #[allow(clippy::cast_possible_wrap)]
132        let sql_limit: i64 = if limit == 0 { -1 } else { limit as i64 };
133        // spec-068 §12.3 / D-2: `acp_sessions.event_count` (migration 106, kept current by
134        // `SessionStore::update_seq` on every turn flush per INV-SP-1) replaces the subquery
135        // against `acp_session_events`, which the P1 write cutover leaves permanently empty for
136        // post-cutover sessions.
137        // `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project
138        // both through `Dialect::select_as_text` so they decode into the `String` fields below,
139        // mirroring `agent_sessions.rs::list_agent_sessions`'s fix for the same mismatch.
140        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    /// Fetch metadata for a single ACP session.
174    ///
175    /// Returns `None` if the session does not exist.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the database query fails.
180    pub async fn get_acp_session_info(
181        &self,
182        session_id: &str,
183    ) -> Result<Option<AcpSessionInfo>, MemoryError> {
184        // spec-068 §12.3 / D-2: see `list_acp_sessions` — `event_count` replaces the emptied
185        // `acp_session_events` subquery.
186        // `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres — see `list_acp_sessions`.
187        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    /// Insert multiple events for a session inside a single transaction.
217    ///
218    /// Atomically writes all events or none. More efficient than individual inserts
219    /// for bulk import use cases.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the transaction or any insert fails.
224    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    /// Update the title of an ACP session.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if the database write fails.
249    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    /// Update the title of an ACP session; returns `true` when the row was found and updated.
263    ///
264    /// Eliminates the separate exists-check + update TOCTOU race by relying on a
265    /// single UPDATE statement and inspecting affected rows.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the database write fails.
270    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    /// Persist a snapshot of the session's current config fields (#5373).
284    ///
285    /// Called on graceful `session/close` so a later `session/resume` or `session/fork` of a
286    /// session no longer resident in memory can inherit these values.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the database write fails.
291    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    /// Load the persisted config snapshot for a session, if one was saved (#5373).
311    ///
312    /// Returns `None` when the session has no snapshot yet — either it was never closed
313    /// gracefully, or it predates the config-snapshot migration. Callers should fall back to
314    /// configured defaults in that case.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the database query fails.
319    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    /// Check whether an ACP session record exists.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the database query fails.
351    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    /// Create a new ACP session record with an associated conversation.
361    ///
362    /// # Errors
363    ///
364    /// Returns an error if the database write fails.
365    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    /// Get the conversation ID associated with an ACP session.
384    ///
385    /// Returns `None` if the session has no conversation mapping (legacy session).
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if the database query fails.
390    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    /// Update the conversation mapping for an ACP session.
404    ///
405    /// # Errors
406    ///
407    /// Returns an error if the database write fails.
408    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    /// Copy all messages from one conversation to another, preserving order.
424    ///
425    /// Summaries are intentionally NOT copied: their `first_message_id`/`last_message_id`
426    /// reference message IDs from the source conversation which differ from the new IDs
427    /// assigned to the copied messages, making the compaction cursor incorrect. The forked
428    /// session inherits the full message history and builds its own compaction state from
429    /// scratch. Other per-conversation state also excluded: embeddings (re-indexed on demand),
430    /// deferred tool summaries (treated as fresh context budget).
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if the database write fails.
435    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        // Copy messages in order. Only columns present across all migrations are included;
443        // per-message auto-fields (id, created_at, last_accessed, access_count, qdrant_cleaned)
444        // are excluded so they are generated fresh for the target conversation.
445        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        // Summaries are NOT copied — their message ID boundaries reference the source
457        // conversation and would corrupt the compaction cursor in the forked session.
458        // The forked session builds compaction state from its own messages.
459
460        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    /// Bump `acp_sessions.event_count` and `updated_at`, mirroring the `UPDATE`
476    /// `zeph_session::SessionStore::update_seq` issues in production (spec-068 §12.3 / D-2).
477    /// `list_acp_sessions`/`get_acp_session_info` read `event_count`, not the legacy
478    /// `acp_session_events` table that `save_acp_event` populates — tests asserting on
479    /// `message_count` (or activity ordering, which depends on `updated_at`) must drive both
480    /// through this column directly rather than the retired write path.
481    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        // Sleep so that sess-a's events land in a different second than sess-b's
590        // created_at, making the updated_at DESC ordering deterministic.
591        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        // sess-a has events so updated_at is newer — should be first
602        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        // sess-b has no events
607        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        // Small sleep so datetime('now') differs
685        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        // Summaries are intentionally excluded because their first/last_message_id
789        // boundaries would reference source message IDs, corrupting the compaction cursor.
790        let store = make_store().await;
791        let src = store.create_conversation().await.unwrap();
792        store.save_message(src, "user", "hello").await.unwrap();
793        // Insert a summary directly so we can verify it is not copied.
794        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}