Skip to main content

robit_agent/
storage.rs

1//! Session and message storage helpers.
2
3use std::path::{Path, PathBuf};
4
5use async_openai::types::chat::{
6    ChatCompletionRequestAssistantMessage, ChatCompletionRequestMessage,
7    ChatCompletionRequestToolMessage, ChatCompletionRequestUserMessage,
8    ChatCompletionRequestUserMessageContent,
9};
10use rusqlite::{params, Connection, Result as SqliteResult};
11use serde::{Deserialize, Serialize};
12
13use crate::datetime::current_timestamp;
14use crate::error::Result;
15
16const ROBIT_DIR: &str = ".robit";
17const MEMORY_DIR: &str = "memory";
18const DB_FILE: &str = "robit.db";
19
20/// Resolve the session database path for a working directory and storage scope.
21pub fn resolve_db_path(working_dir: &Path, global_storage: bool) -> Result<PathBuf> {
22    if global_storage {
23        let home = dirs::home_dir().ok_or_else(|| {
24            crate::error::AgentError::InternalError("Cannot determine home directory".to_string())
25        })?;
26        Ok(home.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
27    } else {
28        Ok(working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE))
29    }
30}
31
32/// Session metadata returned to frontends.
33#[derive(Debug, Clone, Serialize)]
34pub struct SessionInfo {
35    pub id: String,
36    /// Platform chat identifier (None for GUI/TUI, Some for Bot platforms).
37    pub chat_id: Option<String>,
38    pub title: String,
39    pub model: String,
40    /// Which frontend created the session: "gui" | "tui" | "qq" | "feishu".
41    pub source: String,
42    pub status: String, // "idle" | "ready" | "running"
43    pub created_at: String,
44    pub updated_at: String,
45}
46
47/// Message data returned to frontends.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MessageData {
50    pub id: i64,
51    pub role: String,
52    pub content: String,
53    pub tool_name: Option<String>,
54    pub tool_call_id: Option<String>,
55    pub tool_info: Option<serde_json::Value>,
56    pub created_at: String,
57}
58
59/// Tool call info for storage in message.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolCallInfoData {
62    pub tool_call_id: String,
63    pub name: String,
64    pub arguments: String,
65    pub status: String,
66    pub output: Option<String>,
67    pub requires_confirm: bool,
68}
69
70/// Current schema version. Increment when the schema changes.
71const CURRENT_SCHEMA_VERSION: i32 = 2;
72
73/// Initialize the database: create tables if needed, then run migrations.
74///
75/// This is the single entry point used by all frontends. It auto-detects
76/// fresh databases (version 0) and existing databases, running the migration
77/// chain to bring them up to [`CURRENT_SCHEMA_VERSION`].
78pub fn init_db(conn: &Connection) -> SqliteResult<()> {
79    ensure_meta_table(conn)?;
80
81    let version = read_schema_version(conn)?;
82
83    if version == 0 {
84        // Fresh database — create everything at the current version in one shot.
85        create_all_tables(conn)?;
86        write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
87        tracing::info!(
88            "Database initialized at schema v{}",
89            CURRENT_SCHEMA_VERSION
90        );
91        return Ok(());
92    }
93
94    migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
95    Ok(())
96}
97
98/// Detect the schema version of an existing database.
99///
100/// Returns:
101/// - `0` for a truly fresh database (no `sessions` table, no recorded version).
102/// - `1` for a legacy v1 database that predates `_schema_meta` versioning
103///   (tables exist but no version row was ever written).
104/// - `N` for a versioned database, read from `_schema_meta`.
105fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
106    match conn.query_row(
107        "SELECT value FROM _schema_meta WHERE key = 'version'",
108        [],
109        |row| row.get::<_, String>(0),
110    ) {
111        Ok(v) => v.parse().map_err(|_| {
112            rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
113        }),
114        Err(rusqlite::Error::QueryReturnedNoRows) => {
115            // No recorded version — is this a fresh DB or a legacy v1 DB?
116            if sessions_table_exists(conn)? {
117                Ok(1) // tables exist but unversioned → legacy v1
118            } else {
119                Ok(0) // nothing exists → fresh
120            }
121        }
122        Err(e) => Err(e),
123    }
124}
125
126/// Check whether the `sessions` table exists.
127fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
128    let count: i64 = conn.query_row(
129        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
130        [],
131        |row| row.get(0),
132    )?;
133    Ok(count > 0)
134}
135
136/// Create all tables and indexes at the current schema version (fresh DBs only).
137fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
138    conn.execute_batch(
139        "CREATE TABLE IF NOT EXISTS sessions (
140            id          TEXT PRIMARY KEY,
141            chat_id     TEXT,
142            title       TEXT NOT NULL,
143            model       TEXT NOT NULL,
144            source      TEXT NOT NULL DEFAULT 'gui',
145            created_at  TEXT NOT NULL,
146            updated_at  TEXT NOT NULL,
147            is_active   INTEGER DEFAULT 1
148        );
149
150        CREATE TABLE IF NOT EXISTS messages (
151            id           INTEGER PRIMARY KEY AUTOINCREMENT,
152            session_id   TEXT NOT NULL REFERENCES sessions(id),
153            role         TEXT NOT NULL,
154            content      TEXT NOT NULL,
155            tool_name    TEXT,
156            tool_call_id TEXT,
157            tool_info    TEXT,
158            tokens       INTEGER,
159            created_at   TEXT NOT NULL
160        );
161
162        CREATE INDEX IF NOT EXISTS idx_messages_session
163            ON messages(session_id);
164        CREATE INDEX IF NOT EXISTS idx_messages_created
165            ON messages(session_id, created_at);
166        CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
167            ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
168    )?;
169    Ok(())
170}
171
172/// Run the migration chain from `from` to `to`, one step at a time.
173fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
174    let mut current = from;
175    while current < to {
176        tracing::info!("Migrating database: v{} → v{}", current, current + 1);
177        match current {
178            1 => migrate_v1_to_v2(conn)?,
179            other => {
180                return Err(rusqlite::Error::InvalidParameterName(format!(
181                    "Unknown schema version: {}",
182                    other
183                )))
184            }
185        }
186        current += 1;
187        write_schema_version(conn, current)?;
188        tracing::info!("Database migrated to v{}", current);
189    }
190    Ok(())
191}
192
193/// v1 → v2: add `chat_id`, `source` to sessions; ensure `tool_info` on messages;
194/// add the partial unique index on `sessions.chat_id`.
195///
196/// Each ALTER TABLE is wrapped to ignore "duplicate column" errors so the
197/// migration is idempotent (safe to run on a partially migrated DB).
198fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
199    let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", []);
200    let _ = conn.execute(
201        "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
202        [],
203    );
204    let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", []);
205    conn.execute_batch(
206        "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
207            ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
208    )?;
209    Ok(())
210}
211
212// ============================================================================
213// Schema version helpers (private)
214// ============================================================================
215
216fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
217    conn.execute_batch(
218        "CREATE TABLE IF NOT EXISTS _schema_meta (
219            key   TEXT PRIMARY KEY,
220            value TEXT NOT NULL
221        )",
222    )
223}
224
225fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
226    conn.execute(
227        "INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
228        rusqlite::params![version.to_string()],
229    )?;
230    Ok(())
231}
232
233// ============================================================================
234// Session and message accessors (public)
235// ============================================================================
236
237/// Insert a new session.
238///
239/// `chat_id` is `Some` only for Bot platforms (the platform chat identifier);
240/// pass `None` for GUI/TUI sessions. `source` records which frontend created
241/// the session (`"gui"`, `"tui"`, `"qq"`, `"feishu"`).
242pub fn insert_session(
243    conn: &Connection,
244    id: &str,
245    chat_id: Option<&str>,
246    title: &str,
247    model: &str,
248    source: &str,
249) -> SqliteResult<()> {
250    let now = current_timestamp();
251    conn.execute(
252        "INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
253         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
254        params![id, chat_id, title, model, source, now, now],
255    )?;
256    Ok(())
257}
258
259/// List all active sessions, ordered by most recently updated.
260///
261/// Pass `Some(source)` to filter by frontend (e.g. `"qq"`); `None` returns
262/// sessions from all sources.
263pub fn list_sessions(
264    conn: &Connection,
265    source_filter: Option<&str>,
266) -> SqliteResult<Vec<SessionInfo>> {
267    let sql = if source_filter.is_some() {
268        "SELECT id, chat_id, title, model, source, created_at, updated_at \
269         FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
270    } else {
271        "SELECT id, chat_id, title, model, source, created_at, updated_at \
272         FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
273    };
274    let mut stmt = conn.prepare(sql)?;
275    let rows = if let Some(source) = source_filter {
276        stmt.query_map(params![source], map_session_row)?
277    } else {
278        stmt.query_map([], map_session_row)?
279    };
280    rows.collect()
281}
282
283/// Find an active session by its platform chat identifier.
284///
285/// Returns `None` for GUI/TUI sessions (where `chat_id` is NULL).
286pub fn find_session_by_chat_id(
287    conn: &Connection,
288    chat_id: &str,
289) -> SqliteResult<Option<SessionInfo>> {
290    let mut stmt = conn.prepare(
291        "SELECT id, chat_id, title, model, source, created_at, updated_at \
292         FROM sessions WHERE chat_id = ?1 AND is_active = 1",
293    )?;
294    let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
295    match rows.next() {
296        Some(Ok(session)) => Ok(Some(session)),
297        _ => Ok(None),
298    }
299}
300
301/// Get a single session by ID.
302pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
303    let mut stmt = conn.prepare(
304        "SELECT id, chat_id, title, model, source, created_at, updated_at \
305         FROM sessions WHERE id = ?1 AND is_active = 1",
306    )?;
307    let mut rows = stmt.query_map(params![id], map_session_row)?;
308    match rows.next() {
309        Some(Ok(session)) => Ok(Some(session)),
310        _ => Ok(None),
311    }
312}
313
314/// Row mapper shared by all session SELECT queries.
315fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
316    Ok(SessionInfo {
317        id: row.get(0)?,
318        chat_id: row.get(1)?,
319        title: row.get(2)?,
320        model: row.get(3)?,
321        source: row.get(4)?,
322        status: "idle".to_string(),
323        created_at: row.get(5)?,
324        updated_at: row.get(6)?,
325    })
326}
327
328/// Update a session's title.
329pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
330    let now = current_timestamp();
331    conn.execute(
332        "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
333        params![title, now, id],
334    )?;
335    Ok(())
336}
337
338/// Update a session's updated_at timestamp.
339pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
340    let now = current_timestamp();
341    conn.execute(
342        "UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
343        params![now, id],
344    )?;
345    Ok(())
346}
347
348/// Soft-delete a session.
349pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
350    conn.execute(
351        "UPDATE sessions SET is_active = 0 WHERE id = ?1",
352        params![id],
353    )?;
354    Ok(())
355}
356
357/// Insert a message into a session.
358pub fn insert_message(
359    conn: &Connection,
360    session_id: &str,
361    role: &str,
362    content: &str,
363    tool_name: Option<&str>,
364    tool_call_id: Option<&str>,
365    tool_info: Option<&str>,
366) -> SqliteResult<i64> {
367    let now = current_timestamp();
368    conn.execute(
369        "INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
370        params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
371    )?;
372    Ok(conn.last_insert_rowid())
373}
374
375/// Get all messages for a session, ordered by creation time.
376pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
377    let mut stmt = conn.prepare(
378        "SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
379    )?;
380    let rows = stmt.query_map(params![session_id], |row| {
381        let tool_info_str: Option<String> = row.get(5)?;
382        let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
383        Ok(MessageData {
384            id: row.get(0)?,
385            role: row.get(1)?,
386            content: row.get(2)?,
387            tool_name: row.get(3)?,
388            tool_call_id: row.get(4)?,
389            tool_info,
390            created_at: row.get(6)?,
391        })
392    })?;
393    rows.collect()
394}
395
396/// Update a tool message with result.
397pub fn update_tool_message(
398    conn: &Connection,
399    session_id: &str,
400    tool_call_id: &str,
401    tool_info: &str,
402) -> SqliteResult<()> {
403    conn.execute(
404        "UPDATE messages SET tool_info = ?1 WHERE session_id = ?2 AND tool_call_id = ?3",
405        params![tool_info, session_id, tool_call_id],
406    )?;
407    Ok(())
408}
409
410// ============================================================================
411// Message conversion to/from ChatCompletionRequestMessage (new)
412// ============================================================================
413
414/// Convert stored MessageData to ChatCompletionRequestMessage
415pub fn message_to_chat_message(data: &MessageData) -> Result<ChatCompletionRequestMessage> {
416    match data.role.as_str() {
417        "user" => Ok(ChatCompletionRequestMessage::User(
418            ChatCompletionRequestUserMessage {
419                content: ChatCompletionRequestUserMessageContent::Text(data.content.clone()),
420                name: None,
421            }
422            .into(),
423        )),
424        "assistant" => {
425            // Try to parse tool_calls from tool_info
426            let tool_calls = if let Some(tool_info) = &data.tool_info {
427                if let serde_json::Value::Object(obj) = tool_info {
428                    if let Some(serde_json::Value::Array(arr)) = obj.get("tool_calls") {
429                        use async_openai::types::chat::{
430                            ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
431                        };
432                        let mut calls = Vec::new();
433                        for call_val in arr {
434                            if let Ok(call) =
435                                serde_json::from_value::<ChatCompletionMessageToolCall>(
436                                    call_val.clone(),
437                                )
438                            {
439                                calls.push(ChatCompletionMessageToolCalls::Function(call));
440                            }
441                        }
442                        if !calls.is_empty() {
443                            Some(calls)
444                        } else {
445                            None
446                        }
447                    } else {
448                        None
449                    }
450                } else {
451                    None
452                }
453            } else {
454                None
455            };
456
457            #[allow(deprecated)]
458            Ok(ChatCompletionRequestMessage::Assistant(
459                ChatCompletionRequestAssistantMessage {
460                    content: if data.content.is_empty() {
461                        None
462                    } else {
463                        Some(data.content.clone().into())
464                    },
465                    name: None,
466                    tool_calls,
467                    refusal: None,
468                    audio: None,
469                    function_call: None,
470                }
471                .into(),
472            ))
473        }
474        "tool" => {
475            let tool_call_id = data.tool_call_id.clone().unwrap_or_default();
476            Ok(ChatCompletionRequestMessage::Tool(
477                ChatCompletionRequestToolMessage {
478                    content: data.content.clone().into(),
479                    tool_call_id,
480                }
481                .into(),
482            ))
483        }
484        // Ignore other roles for now (system is regenerated)
485        _ => Err(crate::error::AgentError::InternalError(format!(
486            "Unknown role: {}",
487            data.role
488        ))),
489    }
490}
491
492/// Load all messages for a session and convert to chat messages
493pub fn load_chat_messages(
494    conn: &Connection,
495    session_id: &str,
496) -> Result<Vec<ChatCompletionRequestMessage>> {
497    let messages = get_messages(conn, session_id)?;
498    tracing::debug!(
499        "load_chat_messages: session_id={}, loaded {} messages from DB",
500        session_id,
501        messages.len()
502    );
503
504    let mut result = Vec::with_capacity(messages.len());
505    for (idx, msg) in messages.iter().enumerate() {
506        match message_to_chat_message(&msg) {
507            Ok(chat_msg) => {
508                tracing::debug!(
509                    "  Message {}: role={}, content_len={}",
510                    idx,
511                    msg.role,
512                    msg.content.len()
513                );
514                result.push(chat_msg);
515            }
516            Err(e) => {
517                tracing::warn!("Failed to convert message {}: {}", idx, e);
518            }
519        }
520    }
521    tracing::debug!("load_chat_messages: successfully converted {} messages", result.len());
522    Ok(result)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn resolves_local_db_path() {
531        let working_dir = PathBuf::from("project");
532        let path = resolve_db_path(&working_dir, false).unwrap();
533        assert_eq!(
534            path,
535            working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
536        );
537    }
538
539    #[test]
540    fn session_crud() {
541        let conn = Connection::open_in_memory().unwrap();
542        init_db(&conn).unwrap();
543
544        insert_session(
545            &conn,
546            "test-123",
547            None,
548            "Test Session",
549            "deepseek/deepseek-chat",
550            "gui",
551        )
552        .unwrap();
553
554        let sessions = list_sessions(&conn, None).unwrap();
555        assert_eq!(sessions.len(), 1);
556        assert_eq!(sessions[0].id, "test-123");
557        assert_eq!(sessions[0].title, "Test Session");
558        assert_eq!(sessions[0].source, "gui");
559        assert_eq!(sessions[0].chat_id, None);
560        assert_eq!(sessions[0].status, "idle");
561
562        let session = get_session(&conn, "test-123").unwrap().unwrap();
563        assert_eq!(session.title, "Test Session");
564        assert_eq!(session.source, "gui");
565
566        update_session_title(&conn, "test-123", "Updated Title").unwrap();
567        let updated = get_session(&conn, "test-123").unwrap().unwrap();
568        assert_eq!(updated.title, "Updated Title");
569
570        delete_session(&conn, "test-123").unwrap();
571        assert!(get_session(&conn, "test-123").unwrap().is_none());
572        assert!(list_sessions(&conn, None).unwrap().is_empty());
573    }
574
575    #[test]
576    fn message_operations() {
577        let conn = Connection::open_in_memory().unwrap();
578        init_db(&conn).unwrap();
579
580        insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
581        let user_id = insert_message(
582            &conn,
583            "session-msg",
584            "user",
585            "Hello Robit",
586            None,
587            None,
588            None,
589        )
590        .unwrap();
591        let assistant_id = insert_message(
592            &conn,
593            "session-msg",
594            "assistant",
595            "Hello! How can I help?",
596            None,
597            None,
598            None,
599        )
600        .unwrap();
601
602        let messages = get_messages(&conn, "session-msg").unwrap();
603        assert_eq!(messages.len(), 2);
604        assert_eq!(messages[0].id, user_id);
605        assert_eq!(messages[0].role, "user");
606        assert_eq!(messages[0].content, "Hello Robit");
607        assert_eq!(messages[1].id, assistant_id);
608        assert_eq!(messages[1].role, "assistant");
609        assert_eq!(messages[1].content, "Hello! How can I help?");
610    }
611
612    #[test]
613    fn empty_sessions() {
614        let conn = Connection::open_in_memory().unwrap();
615        init_db(&conn).unwrap();
616
617        let sessions = list_sessions(&conn, None).unwrap();
618        assert_eq!(sessions.len(), 0);
619    }
620
621    #[test]
622    fn get_nonexistent_session() {
623        let conn = Connection::open_in_memory().unwrap();
624        init_db(&conn).unwrap();
625
626        let session = get_session(&conn, "nonexistent").unwrap();
627        assert!(session.is_none());
628    }
629
630    #[test]
631    fn tool_message_update() {
632        let conn = Connection::open_in_memory().unwrap();
633        init_db(&conn).unwrap();
634
635        insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
636        let initial = serde_json::json!({
637            "tool_call_id": "tool-1",
638            "name": "bash",
639            "arguments": "{}",
640            "status": "pending",
641            "requires_confirm": true
642        })
643        .to_string();
644        insert_message(
645            &conn,
646            "session-tool",
647            "tool",
648            "{}",
649            Some("bash"),
650            Some("tool-1"),
651            Some(&initial),
652        )
653        .unwrap();
654
655        let updated = serde_json::json!({
656            "tool_call_id": "tool-1",
657            "status": "success",
658            "output": "done"
659        })
660        .to_string();
661        update_tool_message(&conn, "session-tool", "tool-1", &updated).unwrap();
662
663        let messages = get_messages(&conn, "session-tool").unwrap();
664        assert_eq!(messages.len(), 1);
665        assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
666        assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
667        assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
668        assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
669    }
670
671    #[test]
672    fn chat_id_lookup_and_source_filter() {
673        let conn = Connection::open_in_memory().unwrap();
674        init_db(&conn).unwrap();
675
676        insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
677        insert_session(
678            &conn,
679            "qq-1",
680            Some("group:abc"),
681            "技术讨论群",
682            "model",
683            "qq",
684        )
685        .unwrap();
686        insert_session(
687            &conn,
688            "qq-2",
689            Some("private:xyz"),
690            "私聊",
691            "model",
692            "qq",
693        )
694        .unwrap();
695
696        // find_session_by_chat_id
697        let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
698        assert_eq!(found.id, "qq-1");
699        assert_eq!(found.source, "qq");
700        assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
701
702        // chat_id lookup returns None for GUI sessions (NULL chat_id)
703        assert!(find_session_by_chat_id(&conn, "does-not-exist")
704            .unwrap()
705            .is_none());
706
707        // source filter
708        let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
709        assert_eq!(qq_sessions.len(), 2);
710        assert!(qq_sessions.iter().all(|s| s.source == "qq"));
711
712        let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
713        assert_eq!(gui_sessions.len(), 1);
714        assert_eq!(gui_sessions[0].id, "gui-1");
715
716        // no filter returns all
717        assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
718    }
719
720    #[test]
721    fn chat_id_unique_per_chat() {
722        let conn = Connection::open_in_memory().unwrap();
723        init_db(&conn).unwrap();
724
725        insert_session(
726            &conn,
727            "qq-1",
728            Some("group:abc"),
729            "First",
730            "model",
731            "qq",
732        )
733        .unwrap();
734        // Inserting a second session with the same chat_id must fail (unique index).
735        let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
736        assert!(err.is_err());
737    }
738
739    #[test]
740    fn migrates_legacy_v1_database() {
741        let conn = Connection::open_in_memory().unwrap();
742        // Simulate a legacy v1 database: old schema, no _schema_meta.
743        conn.execute_batch(
744            "CREATE TABLE sessions (
745                id          TEXT PRIMARY KEY,
746                title       TEXT NOT NULL,
747                model       TEXT NOT NULL,
748                created_at  TEXT NOT NULL,
749                updated_at  TEXT NOT NULL,
750                is_active   INTEGER DEFAULT 1
751            );
752            CREATE TABLE messages (
753                id           INTEGER PRIMARY KEY AUTOINCREMENT,
754                session_id   TEXT NOT NULL REFERENCES sessions(id),
755                role         TEXT NOT NULL,
756                content      TEXT NOT NULL,
757                tool_name    TEXT,
758                tool_call_id TEXT,
759                tokens       INTEGER,
760                created_at   TEXT NOT NULL
761            );",
762        )
763        .unwrap();
764        conn.execute(
765            "INSERT INTO sessions (id, title, model, created_at, updated_at) \
766             VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
767            [],
768        )
769        .unwrap();
770
771        // Run init_db — it should detect v0 → ... actually no _schema_meta means version 0,
772        // but tables already exist. create_all_tables uses IF NOT EXISTS so it's safe,
773        // and version is written as current. The legacy row's source defaults to 'gui'.
774        init_db(&conn).unwrap();
775
776        // Schema version is now current.
777        let v: i32 = read_schema_version(&conn).unwrap();
778        assert_eq!(v, CURRENT_SCHEMA_VERSION);
779
780        // New columns exist and legacy data is preserved.
781        let session = get_session(&conn, "legacy-1").unwrap().unwrap();
782        assert_eq!(session.title, "Legacy");
783        assert_eq!(session.source, "gui");
784        assert_eq!(session.chat_id, None);
785    }
786
787    #[test]
788    fn init_db_is_idempotent() {
789        let conn = Connection::open_in_memory().unwrap();
790        init_db(&conn).unwrap();
791        // Running again on an already-current DB must not error.
792        init_db(&conn).unwrap();
793        assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
794    }
795}