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, types::ToSqlOutput, 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 = 5;
72
73// ============================================================================
74// Memory data structures
75// ============================================================================
76
77/// Type of memory entry.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub enum MemoryType {
80    /// Objective fact (e.g., "User likes Rust").
81    Fact,
82    /// User preference (e.g., "Prefer deepseek-chat").
83    Preference,
84    /// Note or documentation (e.g., "Project directory structure").
85    Note,
86    /// Task record (e.g., "Completed XX last time").
87    Task,
88    /// Custom type with a name.
89    Custom(String),
90}
91
92impl MemoryType {
93    pub fn as_str(&self) -> &str {
94        match self {
95            MemoryType::Fact => "fact",
96            MemoryType::Preference => "preference",
97            MemoryType::Note => "note",
98            MemoryType::Task => "task",
99            MemoryType::Custom(s) => s,
100        }
101    }
102
103    pub fn from_str(s: &str) -> Self {
104        match s.to_lowercase().as_str() {
105            "fact" => MemoryType::Fact,
106            "preference" => MemoryType::Preference,
107            "note" => MemoryType::Note,
108            "task" => MemoryType::Task,
109            _ => MemoryType::Custom(s.to_string()),
110        }
111    }
112}
113
114/// A memory entry stored in the database.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Memory {
117    /// Unique ID (UUID v4).
118    pub id: String,
119    /// Associated session ID (optional).
120    pub session_id: Option<String>,
121    /// Associated chat ID (for Bot platforms, optional).
122    pub chat_id: Option<String>,
123    /// Type of memory.
124    pub memory_type: MemoryType,
125    /// Short title for the memory (for retrieval).
126    pub title: String,
127    /// Full content of the memory.
128    pub content: String,
129    /// Tags for categorization and filtering.
130    pub tags: Vec<String>,
131    /// Soft deletion flag.
132    pub is_active: bool,
133    /// ISO 8601 creation timestamp.
134    pub created_at: String,
135    /// ISO 8601 last update timestamp.
136    pub updated_at: String,
137}
138
139/// Filter for memory queries.
140#[derive(Debug, Clone, Default)]
141pub struct MemoryFilter {
142    /// Filter by memory type.
143    pub memory_type: Option<MemoryType>,
144    /// Filter by tags (any match).
145    pub tags: Option<Vec<String>>,
146    /// Filter by session ID.
147    pub session_id: Option<String>,
148    /// Filter by chat ID (Bot platforms).
149    pub chat_id: Option<String>,
150    /// Filter by creation time (only memories created after this time).
151    pub since: Option<String>,
152    /// Only return active memories (default: true).
153    pub only_active: bool,
154}
155
156impl Memory {
157    /// Create a new memory with the given details.
158    pub fn new(
159        title: String,
160        content: String,
161        memory_type: MemoryType,
162        tags: Vec<String>,
163    ) -> Self {
164        let now = current_timestamp();
165        Memory {
166            id: uuid::Uuid::new_v4().to_string(),
167            session_id: None,
168            chat_id: None,
169            memory_type,
170            title,
171            content,
172            tags,
173            is_active: true,
174            created_at: now.clone(),
175            updated_at: now,
176        }
177    }
178
179    /// Attach a session ID to this memory.
180    pub fn with_session_id(mut self, session_id: String) -> Self {
181        self.session_id = Some(session_id);
182        self
183    }
184
185    /// Attach a chat ID to this memory.
186    pub fn with_chat_id(mut self, chat_id: String) -> Self {
187        self.chat_id = Some(chat_id);
188        self
189    }
190}
191
192/// Initialize the database: create tables if needed, then run migrations.
193///
194/// This is the single entry point used by all frontends. It auto-detects
195/// fresh databases (version 0) and existing databases, running the migration
196/// chain to bring them up to [`CURRENT_SCHEMA_VERSION`].
197pub fn init_db(conn: &Connection) -> SqliteResult<()> {
198    ensure_meta_table(conn)?;
199
200    let version = read_schema_version(conn)?;
201
202    if version == 0 {
203        // Fresh database — create everything at the current version in one shot.
204        create_all_tables(conn)?;
205        write_schema_version(conn, CURRENT_SCHEMA_VERSION)?;
206        tracing::info!(
207            "Database initialized at schema v{}",
208            CURRENT_SCHEMA_VERSION
209        );
210        return Ok(());
211    }
212
213    migrate(conn, version, CURRENT_SCHEMA_VERSION)?;
214    Ok(())
215}
216
217/// Detect the schema version of an existing database.
218///
219/// Returns:
220/// - `0` for a truly fresh database (no `sessions` table, no recorded version).
221/// - `1` for a legacy v1 database that predates `_schema_meta` versioning
222///   (tables exist but no version row was ever written).
223/// - `N` for a versioned database, read from `_schema_meta`.
224fn read_schema_version(conn: &Connection) -> SqliteResult<i32> {
225    match conn.query_row(
226        "SELECT value FROM _schema_meta WHERE key = 'version'",
227        [],
228        |row| row.get::<_, String>(0),
229    ) {
230        Ok(v) => v.parse().map_err(|_| {
231            rusqlite::Error::InvalidParameterName(format!("Invalid schema version: {}", v))
232        }),
233        Err(rusqlite::Error::QueryReturnedNoRows) => {
234            // No recorded version — is this a fresh DB or a legacy v1 DB?
235            if sessions_table_exists(conn)? {
236                Ok(1) // tables exist but unversioned → legacy v1
237            } else {
238                Ok(0) // nothing exists → fresh
239            }
240        }
241        Err(e) => Err(e),
242    }
243}
244
245/// Check whether the `sessions` table exists.
246fn sessions_table_exists(conn: &Connection) -> SqliteResult<bool> {
247    let count: i64 = conn.query_row(
248        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
249        [],
250        |row| row.get(0),
251    )?;
252    Ok(count > 0)
253}
254
255/// Create all tables and indexes at the current schema version (fresh DBs only).
256fn create_all_tables(conn: &Connection) -> SqliteResult<()> {
257    conn.execute_batch(
258        "CREATE TABLE IF NOT EXISTS sessions (
259            id          TEXT PRIMARY KEY,
260            chat_id     TEXT,
261            title       TEXT NOT NULL,
262            model       TEXT NOT NULL,
263            source      TEXT NOT NULL DEFAULT 'gui',
264            created_at  TEXT NOT NULL,
265            updated_at  TEXT NOT NULL,
266            is_active   INTEGER DEFAULT 1
267        );
268
269        CREATE TABLE IF NOT EXISTS messages (
270            id           INTEGER PRIMARY KEY AUTOINCREMENT,
271            session_id   TEXT NOT NULL REFERENCES sessions(id),
272            role         TEXT NOT NULL,
273            content      TEXT NOT NULL,
274            tool_name    TEXT,
275            tool_call_id TEXT,
276            tool_info    TEXT,
277            tokens       INTEGER,
278            created_at   TEXT NOT NULL
279        );
280
281        CREATE INDEX IF NOT EXISTS idx_messages_session
282            ON messages(session_id);
283        CREATE INDEX IF NOT EXISTS idx_messages_created
284            ON messages(session_id, created_at);
285        CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
286            ON sessions(chat_id) WHERE chat_id IS NOT NULL AND is_active = 1;
287
288        CREATE TABLE IF NOT EXISTS memories (
289            id           TEXT PRIMARY KEY,
290            session_id   TEXT,
291            chat_id      TEXT,
292            memory_type  TEXT NOT NULL,
293            title        TEXT NOT NULL,
294            content      TEXT NOT NULL,
295            tags         TEXT,
296            is_active    INTEGER DEFAULT 1,
297            created_at   TEXT NOT NULL,
298            updated_at   TEXT NOT NULL,
299
300            FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL
301        );
302
303        CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);
304        CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at DESC);
305        CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
306        CREATE INDEX IF NOT EXISTS idx_memories_chat ON memories(chat_id);
307        CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(is_active) WHERE is_active = 1;
308
309        -- FTS5 virtual table for full-text search on messages
310        CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
311            content,
312            content='messages',
313            content_rowid='id',
314            tokenize='unicode61'
315        );
316
317        -- Triggers to keep FTS index in sync with messages table
318        CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
319            INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
320        END;
321
322        CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
323            INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
324        END;
325
326        CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE OF content ON messages BEGIN
327            INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
328            INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
329        END;",
330    )?;
331    Ok(())
332}
333
334/// Run the migration chain from `from` to `to`, one step at a time.
335fn migrate(conn: &Connection, from: i32, to: i32) -> SqliteResult<()> {
336    let mut current = from;
337    while current < to {
338        tracing::info!("Migrating database: v{} → v{}", current, current + 1);
339        match current {
340            1 => migrate_v1_to_v2(conn)?,
341            2 => migrate_v2_to_v3(conn)?,
342            3 => migrate_v3_to_v4(conn)?,
343            4 => migrate_v4_to_v5(conn)?,
344            other => {
345                return Err(rusqlite::Error::InvalidParameterName(format!(
346                    "Unknown schema version: {}",
347                    other
348                )))
349            }
350        }
351        current += 1;
352        write_schema_version(conn, current)?;
353        tracing::info!("Database migrated to v{}", current);
354    }
355    Ok(())
356}
357
358/// v1 → v2: add `chat_id`, `source` to sessions; ensure `tool_info` on messages;
359/// add the partial unique index on `sessions.chat_id`.
360fn migrate_v1_to_v2(conn: &Connection) -> SqliteResult<()> {
361    let _ = conn.execute("ALTER TABLE sessions ADD COLUMN chat_id TEXT", []);
362    let _ = conn.execute(
363        "ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'gui'",
364        [],
365    );
366    let _ = conn.execute("ALTER TABLE messages ADD COLUMN tool_info TEXT", []);
367    conn.execute_batch(
368        "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chat_id
369            ON sessions(chat_id) WHERE chat_id IS NOT NULL;",
370    )?;
371    Ok(())
372}
373
374/// v2 → v3: add `memories` table for long-term memory.
375fn migrate_v2_to_v3(conn: &Connection) -> SqliteResult<()> {
376    conn.execute_batch(
377        "CREATE TABLE IF NOT EXISTS memories (
378            id           TEXT PRIMARY KEY,
379            session_id   TEXT,
380            chat_id      TEXT,
381            memory_type  TEXT NOT NULL,
382            title        TEXT NOT NULL,
383            content      TEXT NOT NULL,
384            tags         TEXT,
385            is_active    INTEGER DEFAULT 1,
386            created_at   TEXT NOT NULL,
387            updated_at   TEXT NOT NULL,
388
389            FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL
390        );
391
392        CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);
393        CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at DESC);
394        CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
395        CREATE INDEX IF NOT EXISTS idx_memories_chat ON memories(chat_id);
396        CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(is_active) WHERE is_active = 1;",
397    )?;
398    Ok(())
399}
400
401/// v3 → v4: add FTS5 virtual table for full-text search on messages.
402fn migrate_v3_to_v4(conn: &Connection) -> SqliteResult<()> {
403    conn.execute_batch(
404        "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
405            content,
406            content='messages',
407            content_rowid='id',
408            tokenize='unicode61'
409        );
410
411        CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
412            INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
413        END;
414
415        CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
416            INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
417        END;
418
419        CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE OF content ON messages BEGIN
420            INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.id, old.content);
421            INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
422        END;
423
424        -- Backfill existing messages into the FTS index
425        INSERT INTO messages_fts(rowid, content)
426        SELECT id, content FROM messages;",
427    )?;
428    Ok(())
429}
430
431/// v4 → v5: rebuild `idx_sessions_chat_id` to only cover ACTIVE sessions.
432///
433/// The v4 index (`WHERE chat_id IS NOT NULL`) counted archived rows too, so
434/// `/new` (which soft-deletes the old session and inserts a new one with the
435/// same `chat_id`) always failed with a UNIQUE constraint violation. The
436/// rebuilt index allows any number of archived sessions per chat_id while
437/// still enforcing at most one ACTIVE session.
438fn migrate_v4_to_v5(conn: &Connection) -> SqliteResult<()> {
439    // Safe under the v4 index: it guaranteed at most one row per chat_id in
440    // total, so there can be no duplicate active rows to trip the new index.
441    conn.execute_batch(
442        "DROP INDEX IF EXISTS idx_sessions_chat_id;
443        CREATE UNIQUE INDEX idx_sessions_chat_id
444            ON sessions(chat_id) WHERE chat_id IS NOT NULL AND is_active = 1;",
445    )?;
446    Ok(())
447}
448
449// ============================================================================
450// Schema version helpers (private)
451// ============================================================================
452
453fn ensure_meta_table(conn: &Connection) -> SqliteResult<()> {
454    conn.execute_batch(
455        "CREATE TABLE IF NOT EXISTS _schema_meta (
456            key   TEXT PRIMARY KEY,
457            value TEXT NOT NULL
458        )",
459    )
460}
461
462fn write_schema_version(conn: &Connection, version: i32) -> SqliteResult<()> {
463    conn.execute(
464        "INSERT OR REPLACE INTO _schema_meta (key, value) VALUES ('version', ?1)",
465        rusqlite::params![version.to_string()],
466    )?;
467    Ok(())
468}
469
470// ============================================================================
471// Session and message accessors (public)
472// ============================================================================
473
474/// Insert a new session.
475///
476/// `chat_id` is `Some` only for Bot platforms (the platform chat identifier);
477/// pass `None` for GUI/TUI sessions. `source` records which frontend created
478/// the session (`"gui"`, `"tui"`, `"qq"`, `"feishu"`).
479pub fn insert_session(
480    conn: &Connection,
481    id: &str,
482    chat_id: Option<&str>,
483    title: &str,
484    model: &str,
485    source: &str,
486) -> SqliteResult<()> {
487    let now = current_timestamp();
488    conn.execute(
489        "INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at) \
490         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
491        params![id, chat_id, title, model, source, now, now],
492    )?;
493    Ok(())
494}
495
496/// List all active sessions, ordered by most recently updated.
497///
498/// Pass `Some(source)` to filter by frontend (e.g. `"qq"`); `None` returns
499/// sessions from all sources.
500pub fn list_sessions(
501    conn: &Connection,
502    source_filter: Option<&str>,
503) -> SqliteResult<Vec<SessionInfo>> {
504    let sql = if source_filter.is_some() {
505        "SELECT id, chat_id, title, model, source, created_at, updated_at \
506         FROM sessions WHERE is_active = 1 AND source = ?1 ORDER BY updated_at DESC"
507    } else {
508        "SELECT id, chat_id, title, model, source, created_at, updated_at \
509         FROM sessions WHERE is_active = 1 ORDER BY updated_at DESC"
510    };
511    let mut stmt = conn.prepare(sql)?;
512    let rows = if let Some(source) = source_filter {
513        stmt.query_map(params![source], map_session_row)?
514    } else {
515        stmt.query_map([], map_session_row)?
516    };
517    rows.collect()
518}
519
520/// Find an active session by its platform chat identifier.
521///
522/// Returns `None` for GUI/TUI sessions (where `chat_id` is NULL).
523pub fn find_session_by_chat_id(
524    conn: &Connection,
525    chat_id: &str,
526) -> SqliteResult<Option<SessionInfo>> {
527    let mut stmt = conn.prepare(
528        "SELECT id, chat_id, title, model, source, created_at, updated_at \
529         FROM sessions WHERE chat_id = ?1 AND is_active = 1",
530    )?;
531    let mut rows = stmt.query_map(params![chat_id], map_session_row)?;
532    match rows.next() {
533        Some(Ok(session)) => Ok(Some(session)),
534        _ => Ok(None),
535    }
536}
537
538/// List ALL sessions (including inactive ones) for a chat_id, ordered by most recent first.
539pub fn list_all_sessions_by_chat_id(
540    conn: &Connection,
541    chat_id: &str,
542) -> SqliteResult<Vec<SessionInfo>> {
543    let mut stmt = conn.prepare(
544        "SELECT id, chat_id, title, model, source, created_at, updated_at \
545         FROM sessions WHERE chat_id = ?1 ORDER BY updated_at DESC",
546    )?;
547    let rows = stmt.query_map(params![chat_id], map_session_row)?;
548    rows.collect()
549}
550
551/// Activate a specific session and deactivate others for the same chat_id.
552pub fn activate_session(
553    conn: &Connection,
554    session_id: &str,
555    chat_id: &str,
556) -> SqliteResult<()> {
557    let now = current_timestamp();
558    // First deactivate all sessions for this chat_id
559    conn.execute(
560        "UPDATE sessions SET is_active = 0 WHERE chat_id = ?1",
561        params![chat_id],
562    )?;
563    // Then activate the target session and update its timestamp
564    conn.execute(
565        "UPDATE sessions SET is_active = 1, updated_at = ?1 WHERE id = ?2",
566        params![now, session_id],
567    )?;
568    Ok(())
569}
570
571/// Get a single session by ID.
572pub fn get_session(conn: &Connection, id: &str) -> SqliteResult<Option<SessionInfo>> {
573    let mut stmt = conn.prepare(
574        "SELECT id, chat_id, title, model, source, created_at, updated_at \
575         FROM sessions WHERE id = ?1 AND is_active = 1",
576    )?;
577    let mut rows = stmt.query_map(params![id], map_session_row)?;
578    match rows.next() {
579        Some(Ok(session)) => Ok(Some(session)),
580        _ => Ok(None),
581    }
582}
583
584/// Row mapper shared by all session SELECT queries.
585fn map_session_row(row: &rusqlite::Row<'_>) -> SqliteResult<SessionInfo> {
586    Ok(SessionInfo {
587        id: row.get(0)?,
588        chat_id: row.get(1)?,
589        title: row.get(2)?,
590        model: row.get(3)?,
591        source: row.get(4)?,
592        status: "idle".to_string(),
593        created_at: row.get(5)?,
594        updated_at: row.get(6)?,
595    })
596}
597
598/// Update a session's title.
599pub fn update_session_title(conn: &Connection, id: &str, title: &str) -> SqliteResult<()> {
600    let now = current_timestamp();
601    conn.execute(
602        "UPDATE sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
603        params![title, now, id],
604    )?;
605    Ok(())
606}
607
608/// Update a session's updated_at timestamp.
609pub fn touch_session(conn: &Connection, id: &str) -> SqliteResult<()> {
610    let now = current_timestamp();
611    conn.execute(
612        "UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
613        params![now, id],
614    )?;
615    Ok(())
616}
617
618/// Soft-delete a session.
619pub fn delete_session(conn: &Connection, id: &str) -> SqliteResult<()> {
620    conn.execute(
621        "UPDATE sessions SET is_active = 0 WHERE id = ?1",
622        params![id],
623    )?;
624    Ok(())
625}
626
627/// Insert a message into a session.
628pub fn insert_message(
629    conn: &Connection,
630    session_id: &str,
631    role: &str,
632    content: &str,
633    tool_name: Option<&str>,
634    tool_call_id: Option<&str>,
635    tool_info: Option<&str>,
636) -> SqliteResult<i64> {
637    let now = current_timestamp();
638    conn.execute(
639        "INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_info, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
640        params![session_id, role, content, tool_name, tool_call_id, tool_info, now],
641    )?;
642    Ok(conn.last_insert_rowid())
643}
644
645/// Get all messages for a session, ordered by creation time.
646pub fn get_messages(conn: &Connection, session_id: &str) -> SqliteResult<Vec<MessageData>> {
647    let mut stmt = conn.prepare(
648        "SELECT id, role, content, tool_name, tool_call_id, tool_info, created_at FROM messages WHERE session_id = ?1 ORDER BY id ASC"
649    )?;
650    let rows = stmt.query_map(params![session_id], |row| {
651        let tool_info_str: Option<String> = row.get(5)?;
652        let tool_info = tool_info_str.and_then(|s| serde_json::from_str(&s).ok());
653        Ok(MessageData {
654            id: row.get(0)?,
655            role: row.get(1)?,
656            content: row.get(2)?,
657            tool_name: row.get(3)?,
658            tool_call_id: row.get(4)?,
659            tool_info,
660            created_at: row.get(6)?,
661        })
662    })?;
663    rows.collect()
664}
665
666/// Update a tool message with its result.
667///
668/// `content` replaces the message content (initially the call arguments,
669/// replaced by the actual tool output once available) so that restored
670/// history carries the real result. `tool_info` is the UI-facing JSON state.
671pub fn update_tool_message(
672    conn: &Connection,
673    session_id: &str,
674    tool_call_id: &str,
675    content: &str,
676    tool_info: &str,
677) -> SqliteResult<()> {
678    conn.execute(
679        "UPDATE messages SET content = ?1, tool_info = ?2 WHERE session_id = ?3 AND tool_call_id = ?4",
680        params![content, tool_info, session_id, tool_call_id],
681    )?;
682    Ok(())
683}
684
685// ============================================================================
686// Message conversion to/from ChatCompletionRequestMessage (new)
687// ============================================================================
688
689/// Convert stored MessageData to ChatCompletionRequestMessage
690pub fn message_to_chat_message(data: &MessageData) -> Result<ChatCompletionRequestMessage> {
691    match data.role.as_str() {
692        "user" => Ok(ChatCompletionRequestMessage::User(
693            ChatCompletionRequestUserMessage {
694                content: ChatCompletionRequestUserMessageContent::Text(data.content.clone()),
695                name: None,
696            }
697            .into(),
698        )),
699        "assistant" => {
700            // Try to parse tool_calls from tool_info
701            let tool_calls = if let Some(tool_info) = &data.tool_info {
702                if let serde_json::Value::Object(obj) = tool_info {
703                    // First try the "tool_calls" field (for complete tool calls)
704                    if let Some(serde_json::Value::Array(arr)) = obj.get("tool_calls") {
705                        use async_openai::types::chat::{
706                            ChatCompletionMessageToolCall, ChatCompletionMessageToolCalls,
707                        };
708                        let mut calls = Vec::new();
709                        for call_val in arr {
710                            if let Ok(call) =
711                                serde_json::from_value::<ChatCompletionMessageToolCall>(
712                                    call_val.clone(),
713                                )
714                            {
715                                calls.push(ChatCompletionMessageToolCalls::Function(call));
716                            }
717                        }
718                        if !calls.is_empty() {
719                            Some(calls)
720                        } else {
721                            None
722                        }
723                    } else {
724                        None
725                    }
726                } else {
727                    None
728                }
729            } else {
730                None
731            };
732
733            let content = if data.content.is_empty() {
734                None
735            } else {
736                Some(data.content.clone().into())
737            };
738
739            // Ensure assistant message has either content or tool_calls
740            if content.is_none() && tool_calls.is_none() {
741                // Skip this invalid message - it will cause API error
742                tracing::warn!("Skipping invalid assistant message: both content and tool_calls are None (message id: {:?})", data.id);
743                return Err(crate::error::AgentError::InternalError("Invalid assistant message".to_string()));
744            }
745
746            Ok(ChatCompletionRequestMessage::Assistant(
747                ChatCompletionRequestAssistantMessage {
748                    content,
749                    name: None,
750                    tool_calls,
751                    refusal: None,
752                    audio: None,
753                    #[allow(deprecated)]
754                    function_call: None,
755                }
756                .into(),
757            ))
758        }
759        "tool" => {
760            let tool_call_id = data.tool_call_id.clone().unwrap_or_default();
761            Ok(ChatCompletionRequestMessage::Tool(
762                ChatCompletionRequestToolMessage {
763                    content: data.content.clone().into(),
764                    tool_call_id,
765                }
766                .into(),
767            ))
768        }
769        // Ignore other roles for now (system is regenerated)
770        _ => Err(crate::error::AgentError::InternalError(format!(
771            "Unknown role: {}",
772            data.role
773        ))),
774    }
775}
776
777// ============================================================================
778// Message full-text search
779// ============================================================================
780
781/// A message search result from FTS full-text search.
782#[derive(Debug, Clone, Serialize)]
783pub struct MessageSearchResult {
784    pub message_id: i64,
785    pub session_id: String,
786    pub session_title: String,
787    pub role: String,
788    pub content_snippet: String,
789    pub created_at: String,
790}
791
792/// Filter for message search queries.
793#[derive(Debug, Clone, Default)]
794pub struct MessageSearchFilter<'a> {
795    /// Search within a specific session (None means all sessions).
796    pub session_id: Option<&'a str>,
797    /// Filter by message role: "user", "assistant", "tool".
798    pub role: Option<&'a str>,
799    /// Only messages created after this ISO 8601 timestamp.
800    pub since: Option<&'a str>,
801    /// Only messages created before this ISO 8601 timestamp.
802    pub until: Option<&'a str>,
803}
804
805/// Search messages using FTS5 full-text search.
806///
807/// `query` is an FTS5 match expression (supports prefix queries with `*`,
808/// phrase queries with quotes, AND/OR/NOT operators).
809///
810/// Returns results ordered by relevance (FTS5 bm25), with snippets.
811pub fn search_messages(
812    conn: &Connection,
813    query: &str,
814    filter: &MessageSearchFilter,
815    limit: usize,
816) -> SqliteResult<Vec<MessageSearchResult>> {
817    if query.trim().is_empty() {
818        return Ok(Vec::new());
819    }
820
821    let mut conditions: Vec<String> = Vec::new();
822    let mut params: Vec<ToSqlOutput> = Vec::new();
823
824    // FTS query is always the first param (bound to ?1)
825    params.push(ToSqlOutput::from(query));
826
827    // Session filter
828    if let Some(session_id) = filter.session_id {
829        conditions.push("m.session_id = ?".to_string());
830        params.push(ToSqlOutput::from(session_id));
831    }
832
833    // Role filter
834    if let Some(role) = filter.role {
835        conditions.push("m.role = ?".to_string());
836        params.push(ToSqlOutput::from(role));
837    }
838
839    // Since filter
840    if let Some(since) = filter.since {
841        conditions.push("m.created_at >= ?".to_string());
842        params.push(ToSqlOutput::from(since));
843    }
844
845    // Until filter
846    if let Some(until) = filter.until {
847        conditions.push("m.created_at <= ?".to_string());
848        params.push(ToSqlOutput::from(until));
849    }
850
851    let where_extra = if conditions.is_empty() {
852        String::new()
853    } else {
854        format!(" AND {}", conditions.join(" AND "))
855    };
856
857    let sql = format!(
858        "SELECT
859            m.id,
860            m.session_id,
861            s.title,
862            m.role,
863            snippet(messages_fts, 0, '<b>', '</b>', '...', 16),
864            m.created_at
865         FROM messages_fts
866         JOIN messages m ON m.id = messages_fts.rowid
867         JOIN sessions s ON s.id = m.session_id
868         WHERE messages_fts MATCH ?1{}
869         ORDER BY bm25(messages_fts)
870         LIMIT {}",
871        where_extra,
872        limit
873    );
874
875    let mut stmt = conn.prepare(&sql)?;
876    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
877    let rows = stmt.query_map(param_refs.as_slice(), |row| {
878        Ok(MessageSearchResult {
879            message_id: row.get(0)?,
880            session_id: row.get(1)?,
881            session_title: row.get(2)?,
882            role: row.get(3)?,
883            content_snippet: row.get(4)?,
884            created_at: row.get(5)?,
885        })
886    })?;
887
888    rows.collect()
889}
890
891/// Load all messages for a session and convert to chat messages
892pub fn load_chat_messages(
893    conn: &Connection,
894    session_id: &str,
895) -> Result<Vec<ChatCompletionRequestMessage>> {
896    let messages = get_messages(conn, session_id)?;
897    tracing::debug!(
898        "load_chat_messages: session_id={}, loaded {} messages from DB",
899        session_id,
900        messages.len()
901    );
902
903    let mut result = Vec::with_capacity(messages.len());
904    for (idx, msg) in messages.iter().enumerate() {
905        match message_to_chat_message(&msg) {
906            Ok(chat_msg) => {
907                // Double-check that assistant messages are valid before adding
908                let is_valid = match &chat_msg {
909                    ChatCompletionRequestMessage::Assistant(assistant_msg) => {
910                        assistant_msg.content.is_some() || assistant_msg.tool_calls.is_some()
911                    }
912                    _ => true,
913                };
914
915                if is_valid {
916                    tracing::trace!(
917                        "load_chat_messages:   message {}: role={}, content_len={}",
918                        idx,
919                        msg.role,
920                        msg.content.len()
921                    );
922                    result.push(chat_msg);
923                } else {
924                    tracing::warn!(
925                        "Skipping invalid assistant message {} (has neither content nor tool_calls)",
926                        idx
927                    );
928                }
929            }
930            Err(e) => {
931                tracing::warn!("Skipping invalid message {}: {}", idx, e);
932            }
933        }
934    }
935    tracing::debug!("load_chat_messages: successfully converted {} messages", result.len());
936    Ok(result)
937}
938
939// ============================================================================
940// Memory accessors (public)
941// ============================================================================
942
943/// Insert a new memory into the database.
944pub fn insert_memory(conn: &Connection, memory: &Memory) -> SqliteResult<()> {
945    let tags_str = if memory.tags.is_empty() {
946        None
947    } else {
948        Some(memory.tags.join(","))
949    };
950
951    conn.execute(
952        "INSERT INTO memories (
953            id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
954        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
955        params![
956            memory.id,
957            memory.session_id,
958            memory.chat_id,
959            memory.memory_type.as_str(),
960            memory.title,
961            memory.content,
962            tags_str,
963            memory.is_active,
964            memory.created_at,
965            memory.updated_at,
966        ],
967    )?;
968    Ok(())
969}
970
971/// Update an existing memory.
972pub fn update_memory(conn: &Connection, memory: &Memory) -> SqliteResult<()> {
973    let tags_str = if memory.tags.is_empty() {
974        None
975    } else {
976        Some(memory.tags.join(","))
977    };
978
979    conn.execute(
980        "UPDATE memories SET
981            title = ?1,
982            content = ?2,
983            tags = ?3,
984            memory_type = ?4,
985            updated_at = ?5
986        WHERE id = ?6",
987        params![
988            memory.title,
989            memory.content,
990            tags_str,
991            memory.memory_type.as_str(),
992            current_timestamp(),
993            memory.id,
994        ],
995    )?;
996    Ok(())
997}
998
999/// Deactivate a memory (soft delete).
1000pub fn deactivate_memory(conn: &Connection, memory_id: &str) -> SqliteResult<()> {
1001    conn.execute(
1002        "UPDATE memories SET is_active = 0, updated_at = ?1 WHERE id = ?2",
1003        params![current_timestamp(), memory_id],
1004    )?;
1005    Ok(())
1006}
1007
1008/// Permanently delete a memory (hard delete).
1009pub fn delete_memory_permanently(conn: &Connection, memory_id: &str) -> SqliteResult<()> {
1010    conn.execute("DELETE FROM memories WHERE id = ?1", params![memory_id])?;
1011    Ok(())
1012}
1013
1014/// Get a single memory by ID.
1015pub fn get_memory(conn: &Connection, memory_id: &str) -> SqliteResult<Option<Memory>> {
1016    let mut stmt = conn.prepare(
1017        "SELECT id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
1018         FROM memories WHERE id = ?1",
1019    )?;
1020
1021    let mut rows = stmt.query_map(params![memory_id], map_memory_row)?;
1022    rows.next().transpose()
1023}
1024
1025/// Find memories by title (fuzzy match using LIKE).
1026pub fn find_memories_by_title(
1027    conn: &Connection,
1028    title_part: &str,
1029    filter: &MemoryFilter,
1030    limit: Option<usize>,
1031) -> SqliteResult<Vec<Memory>> {
1032    let (sql, params) = build_memory_query(Some(title_part), filter, limit);
1033    let mut stmt = conn.prepare(&sql)?;
1034
1035    let rows = stmt.query_map(rusqlite::params_from_iter(params), map_memory_row)?;
1036    rows.collect()
1037}
1038
1039/// List memories with optional filtering.
1040pub fn list_memories(
1041    conn: &Connection,
1042    filter: &MemoryFilter,
1043    limit: Option<usize>,
1044) -> SqliteResult<Vec<Memory>> {
1045    let (sql, params) = build_memory_query(None, filter, limit);
1046    let mut stmt = conn.prepare(&sql)?;
1047
1048    let rows = stmt.query_map(rusqlite::params_from_iter(params), map_memory_row)?;
1049    rows.collect()
1050}
1051
1052/// Recall memories using keyword search (title, content, tags).
1053pub fn recall_memories(
1054    conn: &Connection,
1055    query: &str,
1056    filter: &MemoryFilter,
1057    limit: usize,
1058) -> SqliteResult<Vec<Memory>> {
1059    // First try exact match in title
1060    let mut results = find_memories_by_title(conn, query, filter, Some(limit))?;
1061
1062    // If we have enough results, return them
1063    if results.len() >= limit {
1064        results.truncate(limit);
1065        return Ok(results);
1066    }
1067
1068    // Otherwise, do a broader search using LIKE on title or content
1069    let remaining = limit - results.len();
1070    let (sql, params) = build_recall_query(query, filter, remaining);
1071    let mut stmt = conn.prepare(&sql)?;
1072
1073    let rows = stmt.query_map(rusqlite::params_from_iter(params), map_memory_row)?;
1074    for row in rows {
1075        let memory = row?;
1076        if !results.iter().any(|m| m.id == memory.id) {
1077            results.push(memory);
1078        }
1079    }
1080
1081    results.truncate(limit);
1082    Ok(results)
1083}
1084
1085// ============================================================================
1086// Memory helpers (private)
1087// ============================================================================
1088
1089fn map_memory_row(row: &rusqlite::Row) -> SqliteResult<Memory> {
1090    let tags_str: Option<String> = row.get(6)?;
1091    let tags = tags_str
1092        .map(|s| {
1093            s.split(',')
1094                .map(|t| t.trim().to_string())
1095                .filter(|t| !t.is_empty())
1096                .collect()
1097        })
1098        .unwrap_or_default();
1099
1100    Ok(Memory {
1101        id: row.get(0)?,
1102        session_id: row.get(1)?,
1103        chat_id: row.get(2)?,
1104        memory_type: MemoryType::from_str(&row.get::<_, String>(3)?),
1105        title: row.get(4)?,
1106        content: row.get(5)?,
1107        tags,
1108        is_active: row.get::<_, i32>(7)? != 0,
1109        created_at: row.get(8)?,
1110        updated_at: row.get(9)?,
1111    })
1112}
1113
1114fn build_memory_query<'a>(
1115    title_search: Option<&'a str>,
1116    filter: &'a MemoryFilter,
1117    limit: Option<usize>,
1118) -> (String, Vec<rusqlite::types::ToSqlOutput<'a>>) {
1119    let mut conditions = Vec::new();
1120    let mut params: Vec<rusqlite::types::ToSqlOutput> = Vec::new();
1121
1122    // Active flag
1123    if filter.only_active {
1124        conditions.push("is_active = 1".to_string());
1125    }
1126
1127    // Type filter
1128    if let Some(memory_type) = &filter.memory_type {
1129        conditions.push("memory_type = ?".to_string());
1130        params.push(memory_type.as_str().into());
1131    }
1132
1133    // Session ID
1134    if let Some(session_id) = &filter.session_id {
1135        conditions.push("session_id = ?".to_string());
1136        params.push(session_id.as_str().into());
1137    }
1138
1139    // Chat ID
1140    if let Some(chat_id) = &filter.chat_id {
1141        conditions.push("chat_id = ?".to_string());
1142        params.push(chat_id.as_str().into());
1143    }
1144
1145    // Since time
1146    if let Some(since) = &filter.since {
1147        conditions.push("created_at >= ?".to_string());
1148        params.push(since.as_str().into());
1149    }
1150
1151    // Title search
1152    if let Some(title) = title_search {
1153        conditions.push("title LIKE ?".to_string());
1154        params.push(format!("%{}%", title).into());
1155    }
1156
1157    // Tags filter (any match)
1158    if let Some(tags) = &filter.tags {
1159        if !tags.is_empty() {
1160            let tag_conditions: Vec<_> = tags.iter().map(|_| "tags LIKE ?").collect();
1161            conditions.push(format!("({})", tag_conditions.join(" OR ")));
1162            for tag in tags {
1163                params.push(format!("%{}%", tag).into());
1164            }
1165        }
1166    }
1167
1168    let where_clause = if conditions.is_empty() {
1169        String::new()
1170    } else {
1171        format!("WHERE {}", conditions.join(" AND "))
1172    };
1173
1174    let limit_clause = limit.map(|l| format!("LIMIT {}", l)).unwrap_or_default();
1175
1176    let sql = format!(
1177        "SELECT id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
1178         FROM memories
1179         {}
1180         ORDER BY created_at DESC
1181         {}",
1182        where_clause, limit_clause
1183    );
1184
1185    (sql, params)
1186}
1187
1188fn build_recall_query<'a>(
1189    query: &'a str,
1190    filter: &'a MemoryFilter,
1191    limit: usize,
1192) -> (String, Vec<rusqlite::types::ToSqlOutput<'a>>) {
1193    let mut conditions = Vec::new();
1194    let mut params: Vec<rusqlite::types::ToSqlOutput> = Vec::new();
1195
1196    // Active flag
1197    if filter.only_active {
1198        conditions.push("is_active = 1".to_string());
1199    }
1200
1201    // Type filter
1202    if let Some(memory_type) = &filter.memory_type {
1203        conditions.push("memory_type = ?".to_string());
1204        params.push(memory_type.as_str().into());
1205    }
1206
1207    // Session ID
1208    if let Some(session_id) = &filter.session_id {
1209        conditions.push("session_id = ?".to_string());
1210        params.push(session_id.as_str().into());
1211    }
1212
1213    // Chat ID
1214    if let Some(chat_id) = &filter.chat_id {
1215        conditions.push("chat_id = ?".to_string());
1216        params.push(chat_id.as_str().into());
1217    }
1218
1219    // Keyword search (match in title, content, or tags)
1220    conditions.push("(title LIKE ? OR content LIKE ? OR tags LIKE ?)".to_string());
1221    let pattern = format!("%{}%", query);
1222    params.push(pattern.clone().into());
1223    params.push(pattern.clone().into());
1224    params.push(pattern.into());
1225
1226    let where_clause = format!("WHERE {}", conditions.join(" AND "));
1227
1228    let sql = format!(
1229        "SELECT id, session_id, chat_id, memory_type, title, content, tags, is_active, created_at, updated_at
1230         FROM memories
1231         {}
1232         ORDER BY created_at DESC
1233         LIMIT {}",
1234        where_clause, limit
1235    );
1236
1237    (sql, params)
1238}
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::*;
1243
1244    #[test]
1245    fn resolves_local_db_path() {
1246        let working_dir = PathBuf::from("project");
1247        let path = resolve_db_path(&working_dir, false).unwrap();
1248        assert_eq!(
1249            path,
1250            working_dir.join(ROBIT_DIR).join(MEMORY_DIR).join(DB_FILE)
1251        );
1252    }
1253
1254    #[test]
1255    fn session_crud() {
1256        let conn = Connection::open_in_memory().unwrap();
1257        init_db(&conn).unwrap();
1258
1259        insert_session(
1260            &conn,
1261            "test-123",
1262            None,
1263            "Test Session",
1264            "deepseek/deepseek-chat",
1265            "gui",
1266        )
1267        .unwrap();
1268
1269        let sessions = list_sessions(&conn, None).unwrap();
1270        assert_eq!(sessions.len(), 1);
1271        assert_eq!(sessions[0].id, "test-123");
1272        assert_eq!(sessions[0].title, "Test Session");
1273        assert_eq!(sessions[0].source, "gui");
1274        assert_eq!(sessions[0].chat_id, None);
1275        assert_eq!(sessions[0].status, "idle");
1276
1277        let session = get_session(&conn, "test-123").unwrap().unwrap();
1278        assert_eq!(session.title, "Test Session");
1279        assert_eq!(session.source, "gui");
1280
1281        update_session_title(&conn, "test-123", "Updated Title").unwrap();
1282        let updated = get_session(&conn, "test-123").unwrap().unwrap();
1283        assert_eq!(updated.title, "Updated Title");
1284
1285        delete_session(&conn, "test-123").unwrap();
1286        assert!(get_session(&conn, "test-123").unwrap().is_none());
1287        assert!(list_sessions(&conn, None).unwrap().is_empty());
1288    }
1289
1290    #[test]
1291    fn message_operations() {
1292        let conn = Connection::open_in_memory().unwrap();
1293        init_db(&conn).unwrap();
1294
1295        insert_session(&conn, "session-msg", None, "Chat Session", "model", "gui").unwrap();
1296        let user_id = insert_message(
1297            &conn,
1298            "session-msg",
1299            "user",
1300            "Hello Robit",
1301            None,
1302            None,
1303            None,
1304        )
1305        .unwrap();
1306        let assistant_id = insert_message(
1307            &conn,
1308            "session-msg",
1309            "assistant",
1310            "Hello! How can I help?",
1311            None,
1312            None,
1313            None,
1314        )
1315        .unwrap();
1316
1317        let messages = get_messages(&conn, "session-msg").unwrap();
1318        assert_eq!(messages.len(), 2);
1319        assert_eq!(messages[0].id, user_id);
1320        assert_eq!(messages[0].role, "user");
1321        assert_eq!(messages[0].content, "Hello Robit");
1322        assert_eq!(messages[1].id, assistant_id);
1323        assert_eq!(messages[1].role, "assistant");
1324        assert_eq!(messages[1].content, "Hello! How can I help?");
1325    }
1326
1327    #[test]
1328    fn empty_sessions() {
1329        let conn = Connection::open_in_memory().unwrap();
1330        init_db(&conn).unwrap();
1331
1332        let sessions = list_sessions(&conn, None).unwrap();
1333        assert_eq!(sessions.len(), 0);
1334    }
1335
1336    #[test]
1337    fn get_nonexistent_session() {
1338        let conn = Connection::open_in_memory().unwrap();
1339        init_db(&conn).unwrap();
1340
1341        let session = get_session(&conn, "nonexistent").unwrap();
1342        assert!(session.is_none());
1343    }
1344
1345    #[test]
1346    fn tool_message_update() {
1347        let conn = Connection::open_in_memory().unwrap();
1348        init_db(&conn).unwrap();
1349
1350        insert_session(&conn, "session-tool", None, "Tool Session", "model", "gui").unwrap();
1351        let initial = serde_json::json!({
1352            "tool_call_id": "tool-1",
1353            "name": "bash",
1354            "arguments": "{}",
1355            "status": "pending",
1356            "requires_confirm": true
1357        })
1358        .to_string();
1359        insert_message(
1360            &conn,
1361            "session-tool",
1362            "tool",
1363            "{}",
1364            Some("bash"),
1365            Some("tool-1"),
1366            Some(&initial),
1367        )
1368        .unwrap();
1369
1370        let updated = serde_json::json!({
1371            "tool_call_id": "tool-1",
1372            "status": "success",
1373            "output": "done"
1374        })
1375        .to_string();
1376        update_tool_message(&conn, "session-tool", "tool-1", "done", &updated).unwrap();
1377
1378        let messages = get_messages(&conn, "session-tool").unwrap();
1379        assert_eq!(messages.len(), 1);
1380        assert_eq!(messages[0].tool_name.as_deref(), Some("bash"));
1381        assert_eq!(messages[0].tool_call_id.as_deref(), Some("tool-1"));
1382        // content is replaced with the actual tool output on update
1383        assert_eq!(messages[0].content, "done");
1384        assert_eq!(messages[0].tool_info.as_ref().unwrap()["status"], "success");
1385        assert_eq!(messages[0].tool_info.as_ref().unwrap()["output"], "done");
1386    }
1387
1388    #[test]
1389    fn chat_id_lookup_and_source_filter() {
1390        let conn = Connection::open_in_memory().unwrap();
1391        init_db(&conn).unwrap();
1392
1393        insert_session(&conn, "gui-1", None, "GUI Session", "model", "gui").unwrap();
1394        insert_session(
1395            &conn,
1396            "qq-1",
1397            Some("group:abc"),
1398            "技术讨论群",
1399            "model",
1400            "qq",
1401        )
1402        .unwrap();
1403        insert_session(
1404            &conn,
1405            "qq-2",
1406            Some("private:xyz"),
1407            "私聊",
1408            "model",
1409            "qq",
1410        )
1411        .unwrap();
1412
1413        // find_session_by_chat_id
1414        let found = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
1415        assert_eq!(found.id, "qq-1");
1416        assert_eq!(found.source, "qq");
1417        assert_eq!(found.chat_id.as_deref(), Some("group:abc"));
1418
1419        // chat_id lookup returns None for GUI sessions (NULL chat_id)
1420        assert!(find_session_by_chat_id(&conn, "does-not-exist")
1421            .unwrap()
1422            .is_none());
1423
1424        // source filter
1425        let qq_sessions = list_sessions(&conn, Some("qq")).unwrap();
1426        assert_eq!(qq_sessions.len(), 2);
1427        assert!(qq_sessions.iter().all(|s| s.source == "qq"));
1428
1429        let gui_sessions = list_sessions(&conn, Some("gui")).unwrap();
1430        assert_eq!(gui_sessions.len(), 1);
1431        assert_eq!(gui_sessions[0].id, "gui-1");
1432
1433        // no filter returns all
1434        assert_eq!(list_sessions(&conn, None).unwrap().len(), 3);
1435    }
1436
1437    #[test]
1438    fn chat_id_unique_per_chat() {
1439        let conn = Connection::open_in_memory().unwrap();
1440        init_db(&conn).unwrap();
1441
1442        insert_session(
1443            &conn,
1444            "qq-1",
1445            Some("group:abc"),
1446            "First",
1447            "model",
1448            "qq",
1449        )
1450        .unwrap();
1451        // Inserting a second session with the same chat_id must fail (unique index).
1452        let err = insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq");
1453        assert!(err.is_err());
1454    }
1455
1456    #[test]
1457    fn archived_session_allows_new_session_same_chat() {
1458        let conn = Connection::open_in_memory().unwrap();
1459        init_db(&conn).unwrap();
1460
1461        insert_session(&conn, "qq-1", Some("group:abc"), "First", "model", "qq").unwrap();
1462        // /new archives the old session (soft delete) before creating a new one.
1463        delete_session(&conn, "qq-1").unwrap();
1464        insert_session(&conn, "qq-2", Some("group:abc"), "Second", "model", "qq").unwrap();
1465
1466        // Still only one ACTIVE session per chat_id.
1467        let err = insert_session(&conn, "qq-3", Some("group:abc"), "Third", "model", "qq");
1468        assert!(err.is_err());
1469
1470        // Lookup by chat_id returns the active session only.
1471        let active = find_session_by_chat_id(&conn, "group:abc").unwrap().unwrap();
1472        assert_eq!(active.id, "qq-2");
1473    }
1474
1475    #[test]
1476    fn migrates_v4_chat_id_index_to_active_only() {
1477        let conn = Connection::open_in_memory().unwrap();
1478        // Simulate a v4 database: the old unique index covers all rows,
1479        // active or not, and an archived session is blocking the chat_id.
1480        ensure_meta_table(&conn).unwrap();
1481        conn.execute_batch(
1482            "CREATE TABLE sessions (
1483                id          TEXT PRIMARY KEY,
1484                chat_id     TEXT,
1485                title       TEXT NOT NULL,
1486                model       TEXT NOT NULL,
1487                source      TEXT NOT NULL DEFAULT 'gui',
1488                created_at  TEXT NOT NULL,
1489                updated_at  TEXT NOT NULL,
1490                is_active   INTEGER DEFAULT 1
1491            );
1492            CREATE UNIQUE INDEX idx_sessions_chat_id
1493                ON sessions(chat_id) WHERE chat_id IS NOT NULL;
1494            INSERT INTO sessions (id, chat_id, title, model, source, created_at, updated_at, is_active)
1495            VALUES ('old-1', 'group:abc', 'Old', 'model', 'qq', '2020-01-01', '2020-01-01', 0);",
1496        )
1497        .unwrap();
1498        write_schema_version(&conn, 4).unwrap();
1499
1500        init_db(&conn).unwrap();
1501        assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
1502
1503        // The archived session no longer blocks a new session for the same chat_id.
1504        insert_session(&conn, "new-1", Some("group:abc"), "New", "model", "qq").unwrap();
1505    }
1506
1507    #[test]
1508    fn migrates_legacy_v1_database() {
1509        let conn = Connection::open_in_memory().unwrap();
1510        // Simulate a legacy v1 database: old schema, no _schema_meta.
1511        conn.execute_batch(
1512            "CREATE TABLE sessions (
1513                id          TEXT PRIMARY KEY,
1514                title       TEXT NOT NULL,
1515                model       TEXT NOT NULL,
1516                created_at  TEXT NOT NULL,
1517                updated_at  TEXT NOT NULL,
1518                is_active   INTEGER DEFAULT 1
1519            );
1520            CREATE TABLE messages (
1521                id           INTEGER PRIMARY KEY AUTOINCREMENT,
1522                session_id   TEXT NOT NULL REFERENCES sessions(id),
1523                role         TEXT NOT NULL,
1524                content      TEXT NOT NULL,
1525                tool_name    TEXT,
1526                tool_call_id TEXT,
1527                tokens       INTEGER,
1528                created_at   TEXT NOT NULL
1529            );",
1530        )
1531        .unwrap();
1532        conn.execute(
1533            "INSERT INTO sessions (id, title, model, created_at, updated_at) \
1534             VALUES ('legacy-1', 'Legacy', 'model', '2020-01-01', '2020-01-01')",
1535            [],
1536        )
1537        .unwrap();
1538
1539        // Run init_db — it should detect v0 → ... actually no _schema_meta means version 0,
1540        // but tables already exist. create_all_tables uses IF NOT EXISTS so it's safe,
1541        // and version is written as current. The legacy row's source defaults to 'gui'.
1542        init_db(&conn).unwrap();
1543
1544        // Schema version is now current.
1545        let v: i32 = read_schema_version(&conn).unwrap();
1546        assert_eq!(v, CURRENT_SCHEMA_VERSION);
1547
1548        // New columns exist and legacy data is preserved.
1549        let session = get_session(&conn, "legacy-1").unwrap().unwrap();
1550        assert_eq!(session.title, "Legacy");
1551        assert_eq!(session.source, "gui");
1552        assert_eq!(session.chat_id, None);
1553    }
1554
1555    #[test]
1556    fn init_db_is_idempotent() {
1557        let conn = Connection::open_in_memory().unwrap();
1558        init_db(&conn).unwrap();
1559        // Running again on an already-current DB must not error.
1560        init_db(&conn).unwrap();
1561        assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
1562    }
1563
1564    // ========================================================================
1565    // Message search tests
1566    // ========================================================================
1567
1568    fn setup_search_test(conn: &Connection) {
1569        init_db(conn).unwrap();
1570        insert_session(conn, "sess-1", None, "Session One", "model", "gui").unwrap();
1571        insert_session(conn, "sess-2", None, "Session Two", "model", "gui").unwrap();
1572
1573        insert_message(conn, "sess-1", "user", "Hello, how do I write Rust code?", None, None, None).unwrap();
1574        insert_message(conn, "sess-1", "assistant", "To write Rust code, start with cargo new.", None, None, None).unwrap();
1575        insert_message(conn, "sess-1", "user", "What about Python?", None, None, None).unwrap();
1576        insert_message(conn, "sess-1", "assistant", "Python is also a great language.", None, None, None).unwrap();
1577
1578        insert_message(conn, "sess-2", "user", "How to deploy a Rust application?", None, None, None).unwrap();
1579        insert_message(conn, "sess-2", "assistant", "You can deploy Rust apps with Docker.", None, None, None).unwrap();
1580    }
1581
1582    #[test]
1583    fn search_messages_basic() {
1584        let conn = Connection::open_in_memory().unwrap();
1585        setup_search_test(&conn);
1586
1587        let filter = MessageSearchFilter {
1588            session_id: None,
1589            role: None,
1590            since: None,
1591            until: None,
1592        };
1593        let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1594        assert!(results.len() >= 2, "Expected at least 2 results for 'Rust', got {}", results.len());
1595
1596        // Verify snippet contains highlighting
1597        assert!(results[0].content_snippet.contains("<b>"));
1598    }
1599
1600    #[test]
1601    fn search_messages_session_filter() {
1602        let conn = Connection::open_in_memory().unwrap();
1603        setup_search_test(&conn);
1604
1605        let filter = MessageSearchFilter {
1606            session_id: Some("sess-1"),
1607            role: None,
1608            since: None,
1609            until: None,
1610        };
1611        let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1612        assert_eq!(results.len(), 2);
1613
1614        let filter2 = MessageSearchFilter {
1615            session_id: Some("sess-2"),
1616            role: None,
1617            since: None,
1618            until: None,
1619        };
1620        let results2 = search_messages(&conn, "Rust", &filter2, 10).unwrap();
1621        assert_eq!(results2.len(), 2);
1622    }
1623
1624    #[test]
1625    fn search_messages_role_filter() {
1626        let conn = Connection::open_in_memory().unwrap();
1627        setup_search_test(&conn);
1628
1629        let filter = MessageSearchFilter {
1630            session_id: Some("sess-1"),
1631            role: Some("user"),
1632            since: None,
1633            until: None,
1634        };
1635        let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1636        assert_eq!(results.len(), 1);
1637        assert_eq!(results[0].role, "user");
1638    }
1639
1640    #[test]
1641    fn search_messages_empty_query() {
1642        let conn = Connection::open_in_memory().unwrap();
1643        setup_search_test(&conn);
1644
1645        let filter = MessageSearchFilter::default();
1646        let results = search_messages(&conn, "", &filter, 10).unwrap();
1647        assert!(results.is_empty());
1648    }
1649
1650    #[test]
1651    fn search_messages_no_results() {
1652        let conn = Connection::open_in_memory().unwrap();
1653        setup_search_test(&conn);
1654
1655        let filter = MessageSearchFilter::default();
1656        let results = search_messages(&conn, "nonexistent_keyword_xyz", &filter, 10).unwrap();
1657        assert!(results.is_empty());
1658    }
1659
1660    #[test]
1661    fn search_messages_limit() {
1662        let conn = Connection::open_in_memory().unwrap();
1663        setup_search_test(&conn);
1664
1665        let filter = MessageSearchFilter {
1666            session_id: None,
1667            role: None,
1668            since: None,
1669            until: None,
1670        };
1671        let results = search_messages(&conn, "Rust", &filter, 2).unwrap();
1672        assert_eq!(results.len(), 2);
1673    }
1674
1675    #[test]
1676    fn search_messages_cross_session_has_session_title() {
1677        let conn = Connection::open_in_memory().unwrap();
1678        setup_search_test(&conn);
1679
1680        let filter = MessageSearchFilter {
1681            session_id: None,
1682            role: None,
1683            since: None,
1684            until: None,
1685        };
1686        let results = search_messages(&conn, "Rust", &filter, 10).unwrap();
1687        // All results should have a session title
1688        for r in &results {
1689            assert!(!r.session_title.is_empty());
1690        }
1691    }
1692
1693    #[test]
1694    fn migration_v3_to_v4_backfill() {
1695        let conn = Connection::open_in_memory().unwrap();
1696
1697        // Create v3 schema manually (no FTS)
1698        conn.execute_batch(
1699            "CREATE TABLE sessions (
1700                id          TEXT PRIMARY KEY,
1701                chat_id     TEXT,
1702                title       TEXT NOT NULL,
1703                model       TEXT NOT NULL,
1704                source      TEXT NOT NULL DEFAULT 'gui',
1705                created_at  TEXT NOT NULL,
1706                updated_at  TEXT NOT NULL,
1707                is_active   INTEGER DEFAULT 1
1708            );
1709
1710            CREATE TABLE messages (
1711                id           INTEGER PRIMARY KEY AUTOINCREMENT,
1712                session_id   TEXT NOT NULL REFERENCES sessions(id),
1713                role         TEXT NOT NULL,
1714                content      TEXT NOT NULL,
1715                tool_name    TEXT,
1716                tool_call_id TEXT,
1717                tool_info    TEXT,
1718                tokens       INTEGER,
1719                created_at   TEXT NOT NULL
1720            );
1721
1722            CREATE TABLE memories (
1723                id           TEXT PRIMARY KEY,
1724                session_id   TEXT,
1725                chat_id      TEXT,
1726                memory_type  TEXT NOT NULL,
1727                title        TEXT NOT NULL,
1728                content      TEXT NOT NULL,
1729                tags         TEXT,
1730                is_active    INTEGER DEFAULT 1,
1731                created_at   TEXT NOT NULL,
1732                updated_at   TEXT NOT NULL
1733            );
1734
1735            CREATE TABLE _schema_meta (
1736                key   TEXT PRIMARY KEY,
1737                value TEXT NOT NULL
1738            );
1739
1740            INSERT INTO _schema_meta (key, value) VALUES ('version', '3');
1741
1742            INSERT INTO sessions (id, title, model, source, created_at, updated_at)
1743            VALUES ('old-sess', 'Old Session', 'model', 'gui', '2025-01-01', '2025-01-01');
1744
1745            INSERT INTO messages (session_id, role, content, created_at)
1746            VALUES ('old-sess', 'user', 'This is a legacy message about testing', '2025-01-01');",
1747        )
1748        .unwrap();
1749
1750        // Run init_db — should migrate v3 to the current version and backfill
1751        init_db(&conn).unwrap();
1752
1753        assert_eq!(read_schema_version(&conn).unwrap(), CURRENT_SCHEMA_VERSION);
1754
1755        // Verify search works on the legacy message
1756        let filter = MessageSearchFilter {
1757            session_id: Some("old-sess"),
1758            role: None,
1759            since: None,
1760            until: None,
1761        };
1762        let results = search_messages(&conn, "legacy", &filter, 10).unwrap();
1763        assert_eq!(results.len(), 1);
1764        assert_eq!(results[0].session_title, "Old Session");
1765    }
1766}