Skip to main content

roboticus_db/
sessions.rs

1use rusqlite::OptionalExtension;
2use serde::{Deserialize, Serialize};
3
4use crate::{Database, DbResultExt};
5use roboticus_core::{RoboticusError, Result};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(tag = "type")]
9pub enum SessionScope {
10    Agent,
11    Peer { peer_id: String, channel: String },
12    Group { group_id: String, channel: String },
13}
14
15impl SessionScope {
16    pub fn scope_key(&self) -> String {
17        match self {
18            Self::Agent => "agent".to_string(),
19            Self::Peer { peer_id, channel } => format!("peer:{channel}:{peer_id}"),
20            Self::Group { group_id, channel } => format!("group:{channel}:{group_id}"),
21        }
22    }
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum SessionStatus {
28    Active,
29    Archived,
30    Expired,
31}
32
33impl std::fmt::Display for SessionStatus {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Active => write!(f, "active"),
37            Self::Archived => write!(f, "archived"),
38            Self::Expired => write!(f, "expired"),
39        }
40    }
41}
42
43impl SessionStatus {
44    pub fn from_str_lossy(s: &str) -> Self {
45        match s {
46            "archived" => Self::Archived,
47            "expired" => Self::Expired,
48            _ => Self::Active,
49        }
50    }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum MessageRole {
56    User,
57    Assistant,
58    System,
59    Tool,
60}
61
62impl std::fmt::Display for MessageRole {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::User => write!(f, "user"),
66            Self::Assistant => write!(f, "assistant"),
67            Self::System => write!(f, "system"),
68            Self::Tool => write!(f, "tool"),
69        }
70    }
71}
72
73impl MessageRole {
74    pub fn from_str_lossy(s: &str) -> Self {
75        match s {
76            "assistant" => Self::Assistant,
77            "system" => Self::System,
78            "tool" => Self::Tool,
79            _ => Self::User,
80        }
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct Session {
86    pub id: String,
87    pub agent_id: String,
88    pub scope_key: Option<String>,
89    pub status: String,
90    pub model: Option<String>,
91    pub nickname: Option<String>,
92    pub created_at: String,
93    pub updated_at: String,
94    pub metadata: Option<String>,
95}
96
97#[derive(Debug, Clone)]
98pub struct Message {
99    pub id: String,
100    pub session_id: String,
101    pub parent_id: Option<String>,
102    pub role: String,
103    pub content: String,
104    pub usage_json: Option<String>,
105    pub created_at: String,
106}
107
108/// Returns the existing active session for `agent_id` (optionally scoped), or creates one.
109pub fn find_or_create(
110    db: &Database,
111    agent_id: &str,
112    scope: Option<&SessionScope>,
113) -> Result<String> {
114    let conn = db.conn();
115    let scope_key = scope
116        .map(|s| s.scope_key())
117        .unwrap_or_else(|| SessionScope::Agent.scope_key());
118
119    let tx = conn.unchecked_transaction().db_err()?;
120
121    let existing: Option<String> = match tx.query_row(
122        "SELECT id FROM sessions WHERE agent_id = ?1 AND scope_key = ?2 AND status = 'active' ORDER BY created_at DESC LIMIT 1",
123        rusqlite::params![agent_id, &scope_key],
124        |row| row.get(0),
125    ) {
126        Ok(id) => Some(id),
127        Err(rusqlite::Error::QueryReturnedNoRows) => None,
128        Err(e) => return Err(RoboticusError::Database(format!("session query failed: {e}"))),
129    };
130
131    if let Some(id) = existing {
132        tx.commit().db_err()?;
133        return Ok(id);
134    }
135
136    let id = uuid::Uuid::new_v4().to_string();
137    let inserted = tx
138        .execute(
139            "INSERT OR IGNORE INTO sessions (id, agent_id, scope_key) VALUES (?1, ?2, ?3)",
140            rusqlite::params![id, agent_id, &scope_key],
141        )
142        .db_err()?;
143
144    if inserted == 0 {
145        let existing_after_conflict: Option<String> = match tx.query_row(
146            "SELECT id FROM sessions WHERE agent_id = ?1 AND scope_key = ?2 AND status = 'active' ORDER BY created_at DESC LIMIT 1",
147            rusqlite::params![agent_id, &scope_key],
148            |row| row.get(0),
149        ) {
150            Ok(id) => Some(id),
151            Err(rusqlite::Error::QueryReturnedNoRows) => None,
152            Err(e) => return Err(RoboticusError::Database(format!("session conflict query failed: {e}"))),
153        };
154        if let Some(id) = existing_after_conflict {
155            tx.commit().db_err()?;
156            return Ok(id);
157        }
158        return Err(RoboticusError::Database(
159            "failed to insert or find active session after conflict".to_string(),
160        ));
161    }
162
163    tx.commit().db_err()?;
164
165    Ok(id)
166}
167
168/// Always creates a new active session for `agent_id` (optionally scoped).
169pub fn create_new(db: &Database, agent_id: &str, scope: Option<&SessionScope>) -> Result<String> {
170    let conn = db.conn();
171    let scope_key = scope
172        .map(|s| s.scope_key())
173        .unwrap_or_else(|| SessionScope::Agent.scope_key());
174    let id = uuid::Uuid::new_v4().to_string();
175    conn.execute(
176        "INSERT INTO sessions (id, agent_id, scope_key) VALUES (?1, ?2, ?3)",
177        rusqlite::params![id, agent_id, scope_key],
178    )
179    .db_err()?;
180    Ok(id)
181}
182
183/// Archive any active agent-scoped session for `agent_id`, then create a new one atomically.
184pub fn rotate_agent_session(db: &Database, agent_id: &str) -> Result<String> {
185    let conn = db.conn();
186    let tx = conn.unchecked_transaction().db_err()?;
187
188    tx.execute(
189        "UPDATE sessions
190         SET status = 'archived', updated_at = datetime('now')
191         WHERE agent_id = ?1
192           AND status = 'active'
193           AND COALESCE(scope_key, 'agent') = 'agent'",
194        rusqlite::params![agent_id],
195    )
196    .db_err()?;
197
198    let id = uuid::Uuid::new_v4().to_string();
199    tx.execute(
200        "INSERT INTO sessions (id, agent_id, scope_key) VALUES (?1, ?2, 'agent')",
201        rusqlite::params![id, agent_id],
202    )
203    .db_err()?;
204
205    tx.commit().db_err()?;
206    Ok(id)
207}
208
209pub fn get_session(db: &Database, id: &str) -> Result<Option<Session>> {
210    let conn = db.conn();
211    conn.query_row(
212        "SELECT id, agent_id, scope_key, status, model, nickname, created_at, updated_at, metadata \
213         FROM sessions WHERE id = ?1",
214        [id],
215        |row| {
216            Ok(Session {
217                id: row.get(0)?,
218                agent_id: row.get(1)?,
219                scope_key: row.get(2)?,
220                status: row.get(3)?,
221                model: row.get(4)?,
222                nickname: row.get(5)?,
223                created_at: row.get(6)?,
224                updated_at: row.get(7)?,
225                metadata: row.get(8)?,
226            })
227        },
228    )
229    .optional()
230    .db_err()
231}
232
233pub fn append_message(
234    db: &Database,
235    session_id: &str,
236    role: &str,
237    content: &str,
238) -> Result<String> {
239    let conn = db.conn();
240    let id = uuid::Uuid::new_v4().to_string();
241    let tx = conn.unchecked_transaction().db_err()?;
242    tx.execute(
243        "INSERT INTO session_messages (id, session_id, role, content) VALUES (?1, ?2, ?3, ?4)",
244        rusqlite::params![id, session_id, role, content],
245    )
246    .db_err()?;
247    tx.execute(
248        "UPDATE sessions SET updated_at = datetime('now') WHERE id = ?1",
249        [session_id],
250    )
251    .db_err()?;
252    tx.commit().db_err()?;
253    Ok(id)
254}
255
256pub fn list_messages(db: &Database, session_id: &str, limit: Option<i64>) -> Result<Vec<Message>> {
257    let conn = db.conn();
258    let effective_limit = limit.unwrap_or(i64::MAX);
259    let mut stmt = conn
260        .prepare(
261            "SELECT id, session_id, parent_id, role, content, usage_json, created_at \
262             FROM session_messages WHERE session_id = ?1 ORDER BY created_at ASC, rowid ASC LIMIT ?2",
263        )
264        .db_err()?;
265
266    let rows = stmt
267        .query_map(rusqlite::params![session_id, effective_limit], |row| {
268            Ok(Message {
269                id: row.get(0)?,
270                session_id: row.get(1)?,
271                parent_id: row.get(2)?,
272                role: row.get(3)?,
273                content: row.get(4)?,
274                usage_json: row.get(5)?,
275                created_at: row.get(6)?,
276            })
277        })
278        .db_err()?;
279
280    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
281}
282
283/// Create a turn record for tool-use tracking within a session.
284pub fn create_turn(
285    db: &Database,
286    session_id: &str,
287    model: Option<&str>,
288    tokens_in: Option<i64>,
289    tokens_out: Option<i64>,
290    cost: Option<f64>,
291) -> Result<String> {
292    let conn = db.conn();
293    let id = uuid::Uuid::new_v4().to_string();
294    conn.execute(
295        "INSERT INTO turns (id, session_id, model, tokens_in, tokens_out, cost) \
296         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
297        rusqlite::params![id, session_id, model, tokens_in, tokens_out, cost],
298    )
299    .db_err()?;
300    Ok(id)
301}
302
303/// Create a turn record with a caller-provided ID.
304pub fn create_turn_with_id(
305    db: &Database,
306    id: &str,
307    session_id: &str,
308    model: Option<&str>,
309    tokens_in: Option<i64>,
310    tokens_out: Option<i64>,
311    cost: Option<f64>,
312) -> Result<()> {
313    let conn = db.conn();
314    conn.execute(
315        "INSERT INTO turns (id, session_id, model, tokens_in, tokens_out, cost) \
316         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
317        rusqlite::params![id, session_id, model, tokens_in, tokens_out, cost],
318    )
319    .db_err()?;
320    Ok(())
321}
322
323/// Update the JSON metadata blob for a session.
324pub fn update_metadata(db: &Database, session_id: &str, metadata_json: &str) -> Result<()> {
325    let conn = db.conn();
326    let changed = conn
327        .execute(
328            "UPDATE sessions SET metadata = ?1, updated_at = datetime('now') WHERE id = ?2",
329            rusqlite::params![metadata_json, session_id],
330        )
331        .db_err()?;
332    if changed == 0 {
333        return Err(RoboticusError::Database(format!(
334            "session not found: {session_id}"
335        )));
336    }
337    Ok(())
338}
339
340/// Mark a session as archived (inactive).
341pub fn archive_session(db: &Database, session_id: &str) -> Result<()> {
342    let conn = db.conn();
343    let changed = conn
344        .execute(
345            "UPDATE sessions SET status = 'archived', updated_at = datetime('now') WHERE id = ?1",
346            [session_id],
347        )
348        .db_err()?;
349    if changed == 0 {
350        return Err(RoboticusError::Database(format!(
351            "session not found: {session_id}"
352        )));
353    }
354    Ok(())
355}
356
357/// Update the nickname for a session.
358pub fn update_nickname(db: &Database, session_id: &str, nickname: &str) -> Result<()> {
359    let conn = db.conn();
360    let changed = conn
361        .execute(
362            "UPDATE sessions SET nickname = ?1, updated_at = datetime('now') WHERE id = ?2",
363            rusqlite::params![nickname, session_id],
364        )
365        .db_err()?;
366    if changed == 0 {
367        return Err(RoboticusError::Database(format!(
368            "session not found: {session_id}"
369        )));
370    }
371    Ok(())
372}
373
374/// Update the model used for a session (best-effort, ignores missing sessions).
375pub fn update_model(db: &Database, session_id: &str, model: &str) -> Result<()> {
376    let conn = db.conn();
377    conn.execute(
378        "UPDATE sessions SET model = ?1, updated_at = datetime('now') WHERE id = ?2",
379        rusqlite::params![model, session_id],
380    )
381    .db_err()?;
382    Ok(())
383}
384
385/// Expire active sessions older than `max_age_seconds`.
386pub fn expire_stale_sessions(db: &Database, max_age_seconds: u64) -> Result<usize> {
387    let conn = db.conn();
388    let expired = conn
389        .execute(
390            "UPDATE sessions SET status = 'expired', updated_at = datetime('now') \
391             WHERE status = 'active' \
392             AND (julianday('now') - julianday(updated_at)) * 86400 > ?1",
393            rusqlite::params![max_age_seconds as f64],
394        )
395        .db_err()?;
396    Ok(expired)
397}
398
399pub fn list_stale_active_session_ids(db: &Database, max_age_seconds: u64) -> Result<Vec<String>> {
400    let conn = db.conn();
401    let mut stmt = conn
402        .prepare(
403            "SELECT id FROM sessions WHERE status = 'active' \
404             AND (julianday('now') - julianday(updated_at)) * 86400 > ?1",
405        )
406        .db_err()?;
407    let rows = stmt
408        .query_map(rusqlite::params![max_age_seconds as f64], |row| row.get(0))
409        .db_err()?;
410    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
411}
412
413pub fn set_session_status(db: &Database, session_id: &str, status: SessionStatus) -> Result<()> {
414    let conn = db.conn();
415    let changed = conn
416        .execute(
417            "UPDATE sessions SET status = ?1, updated_at = datetime('now') WHERE id = ?2",
418            rusqlite::params![status.to_string(), session_id],
419        )
420        .db_err()?;
421    if changed == 0 {
422        return Err(RoboticusError::Database(format!(
423            "session not found: {session_id}"
424        )));
425    }
426    Ok(())
427}
428
429/// List all active sessions, optionally filtered by agent_id.
430pub fn list_active_sessions(db: &Database, agent_id: Option<&str>) -> Result<Vec<Session>> {
431    let conn = db.conn();
432    let mut sessions = Vec::new();
433
434    if let Some(aid) = agent_id {
435        let mut stmt = conn
436            .prepare(
437                "SELECT id, agent_id, scope_key, status, model, nickname, created_at, updated_at, metadata \
438                 FROM sessions WHERE agent_id = ?1 AND status = 'active' ORDER BY created_at DESC",
439            )
440            .db_err()?;
441        let rows = stmt
442            .query_map([aid], |row| {
443                Ok(Session {
444                    id: row.get(0)?,
445                    agent_id: row.get(1)?,
446                    scope_key: row.get(2)?,
447                    status: row.get(3)?,
448                    model: row.get(4)?,
449                    nickname: row.get(5)?,
450                    created_at: row.get(6)?,
451                    updated_at: row.get(7)?,
452                    metadata: row.get(8)?,
453                })
454            })
455            .db_err()?;
456        for row in rows {
457            sessions.push(row.db_err()?);
458        }
459    } else {
460        let mut stmt = conn
461            .prepare(
462                "SELECT id, agent_id, scope_key, status, model, nickname, created_at, updated_at, metadata \
463                 FROM sessions WHERE status = 'active' ORDER BY created_at DESC",
464            )
465            .db_err()?;
466        let rows = stmt
467            .query_map([], |row| {
468                Ok(Session {
469                    id: row.get(0)?,
470                    agent_id: row.get(1)?,
471                    scope_key: row.get(2)?,
472                    status: row.get(3)?,
473                    model: row.get(4)?,
474                    nickname: row.get(5)?,
475                    created_at: row.get(6)?,
476                    updated_at: row.get(7)?,
477                    metadata: row.get(8)?,
478                })
479            })
480            .db_err()?;
481        for row in rows {
482            sessions.push(row.db_err()?);
483        }
484    }
485
486    Ok(sessions)
487}
488
489/// Find the largest byte index <= `max_bytes` that is a valid char boundary.
490fn char_boundary(s: &str, max_bytes: usize) -> usize {
491    if max_bytes >= s.len() {
492        return s.len();
493    }
494    s.char_indices()
495        .map(|(i, _)| i)
496        .take_while(|&i| i <= max_bytes)
497        .last()
498        .unwrap_or(0)
499}
500
501/// Derive a short nickname from the first user message using heuristics.
502pub fn derive_nickname(first_message: &str) -> String {
503    let trimmed = first_message.trim();
504    if trimmed.is_empty() {
505        return "Untitled".into();
506    }
507
508    let greeting_prefixes: &[&str] = &[
509        "hey ",
510        "hi ",
511        "hello ",
512        "yo ",
513        "can you ",
514        "could you ",
515        "please ",
516        "i need ",
517        "i want ",
518        "help me ",
519        "hey, ",
520        "hi, ",
521        "hello, ",
522        "yo, ",
523    ];
524
525    let mut text = trimmed;
526    let lower = text.to_lowercase();
527    for prefix in greeting_prefixes {
528        if lower.starts_with(prefix) {
529            // Use char count to find equivalent byte position in original text,
530            // since to_lowercase() can change byte lengths for non-ASCII chars.
531            let prefix_chars = prefix.chars().count();
532            if let Some((byte_pos, _)) = text.char_indices().nth(prefix_chars) {
533                text = &text[byte_pos..];
534            } else {
535                text = "";
536            }
537            break;
538        }
539    }
540
541    let text = text.trim_start();
542    if text.is_empty() {
543        return "Untitled".into();
544    }
545
546    let sentence_end = text.find(['.', '?', '!', '\n']).unwrap_or(text.len());
547    let end = char_boundary(text, sentence_end.min(60));
548
549    let mut nickname: String = text[..end].trim().to_string();
550
551    if nickname.len() > 50 {
552        let boundary = char_boundary(&nickname, 50);
553        if let Some(break_pos) = nickname[..boundary].rfind(' ') {
554            nickname.truncate(break_pos);
555            nickname.push_str("...");
556        } else {
557            nickname.truncate(boundary);
558            nickname.push_str("...");
559        }
560    }
561
562    if nickname.is_empty() {
563        return "Untitled".into();
564    }
565
566    let mut chars = nickname.chars();
567    let first = chars.next().unwrap().to_uppercase().to_string();
568    format!("{first}{}", chars.as_str())
569}
570
571/// Backfill nicknames for all sessions that don't have one yet.
572/// Returns the number of sessions updated.
573pub fn backfill_nicknames(db: &Database) -> Result<usize> {
574    let conn = db.conn();
575
576    let mut stmt = conn
577        .prepare(
578            "SELECT s.id, \
579                (SELECT content FROM session_messages \
580                 WHERE session_id = s.id AND role = 'user' \
581                 ORDER BY created_at ASC LIMIT 1) \
582             FROM sessions s WHERE s.nickname IS NULL",
583        )
584        .db_err()?;
585
586    let rows: Vec<(String, Option<String>)> = stmt
587        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
588        .db_err()?
589        .filter_map(|r| {
590            r.inspect_err(
591                |e| tracing::warn!(error = %e, "sessions: skipping malformed session row"),
592            )
593            .ok()
594        })
595        .collect();
596
597    let count = rows.len();
598    for (session_id, first_msg) in &rows {
599        let nick = match first_msg {
600            Some(msg) => derive_nickname(msg),
601            None => "Untitled".into(),
602        };
603        conn.execute(
604            "UPDATE sessions SET nickname = ?1 WHERE id = ?2",
605            rusqlite::params![nick, session_id],
606        )
607        .db_err()?;
608    }
609
610    Ok(count)
611}
612
613// ── Turn query helpers ─────────────────────────────────────────
614
615#[derive(Debug, Clone)]
616pub struct TurnRecord {
617    pub id: String,
618    pub session_id: String,
619    pub thinking: Option<String>,
620    pub tool_calls_json: Option<String>,
621    pub tokens_in: Option<i64>,
622    pub tokens_out: Option<i64>,
623    pub cost: Option<f64>,
624    pub model: Option<String>,
625    pub created_at: String,
626}
627
628pub fn list_turns_for_session(db: &Database, session_id: &str) -> Result<Vec<TurnRecord>> {
629    let conn = db.conn();
630    let mut stmt = conn
631        .prepare(
632            "SELECT id, session_id, thinking, tool_calls_json, tokens_in, tokens_out, cost, model, created_at \
633             FROM turns WHERE session_id = ?1 ORDER BY created_at ASC, rowid ASC",
634        )
635        .db_err()?;
636
637    let rows = stmt
638        .query_map([session_id], |row| {
639            Ok(TurnRecord {
640                id: row.get(0)?,
641                session_id: row.get(1)?,
642                thinking: row.get(2)?,
643                tool_calls_json: row.get(3)?,
644                tokens_in: row.get(4)?,
645                tokens_out: row.get(5)?,
646                cost: row.get(6)?,
647                model: row.get(7)?,
648                created_at: row.get(8)?,
649            })
650        })
651        .db_err()?;
652
653    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
654}
655
656pub fn get_turn_by_id(db: &Database, turn_id: &str) -> Result<Option<TurnRecord>> {
657    let conn = db.conn();
658    conn.query_row(
659        "SELECT id, session_id, thinking, tool_calls_json, tokens_in, tokens_out, cost, model, created_at \
660         FROM turns WHERE id = ?1",
661        [turn_id],
662        |row| {
663            Ok(TurnRecord {
664                id: row.get(0)?,
665                session_id: row.get(1)?,
666                thinking: row.get(2)?,
667                tool_calls_json: row.get(3)?,
668                tokens_in: row.get(4)?,
669                tokens_out: row.get(5)?,
670                cost: row.get(6)?,
671                model: row.get(7)?,
672                created_at: row.get(8)?,
673            })
674        },
675    )
676    .optional()
677    .db_err()
678}
679
680// ── Turn feedback ──────────────────────────────────────────────
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct TurnFeedback {
684    pub id: String,
685    pub turn_id: String,
686    pub session_id: String,
687    pub grade: i32,
688    pub source: String,
689    pub comment: Option<String>,
690    pub created_at: String,
691}
692
693pub fn record_feedback(
694    db: &Database,
695    turn_id: &str,
696    session_id: &str,
697    grade: i32,
698    source: &str,
699    comment: Option<&str>,
700) -> Result<String> {
701    let id = uuid::Uuid::new_v4().to_string();
702    db.conn()
703        .execute(
704            "INSERT INTO turn_feedback (id, turn_id, session_id, grade, source, comment) \
705             VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
706             ON CONFLICT(turn_id) DO UPDATE SET grade = excluded.grade, source = excluded.source, comment = excluded.comment",
707            rusqlite::params![id, turn_id, session_id, grade, source, comment],
708        )
709        .db_err()?;
710    Ok(id)
711}
712
713pub fn get_feedback(db: &Database, turn_id: &str) -> Result<Option<TurnFeedback>> {
714    let conn = db.conn();
715    conn.query_row(
716        "SELECT id, turn_id, session_id, grade, source, comment, created_at \
717         FROM turn_feedback WHERE turn_id = ?1 LIMIT 1",
718        [turn_id],
719        |row| {
720            Ok(TurnFeedback {
721                id: row.get(0)?,
722                turn_id: row.get(1)?,
723                session_id: row.get(2)?,
724                grade: row.get(3)?,
725                source: row.get(4)?,
726                comment: row.get(5)?,
727                created_at: row.get(6)?,
728            })
729        },
730    )
731    .optional()
732    .db_err()
733}
734
735pub fn update_feedback(
736    db: &Database,
737    turn_id: &str,
738    grade: i32,
739    comment: Option<&str>,
740) -> Result<()> {
741    let conn = db.conn();
742    let changed = conn
743        .execute(
744            "UPDATE turn_feedback SET grade = ?1, comment = ?2 WHERE turn_id = ?3",
745            rusqlite::params![grade, comment, turn_id],
746        )
747        .db_err()?;
748    if changed == 0 {
749        return Err(RoboticusError::Database(format!(
750            "no feedback found for turn: {turn_id}"
751        )));
752    }
753    Ok(())
754}
755
756pub fn list_session_feedback(db: &Database, session_id: &str) -> Result<Vec<TurnFeedback>> {
757    let conn = db.conn();
758    let mut stmt = conn
759        .prepare(
760            "SELECT id, turn_id, session_id, grade, source, comment, created_at \
761             FROM turn_feedback WHERE session_id = ?1 ORDER BY created_at ASC",
762        )
763        .db_err()?;
764
765    let rows = stmt
766        .query_map([session_id], |row| {
767            Ok(TurnFeedback {
768                id: row.get(0)?,
769                turn_id: row.get(1)?,
770                session_id: row.get(2)?,
771                grade: row.get(3)?,
772                source: row.get(4)?,
773                comment: row.get(5)?,
774                created_at: row.get(6)?,
775            })
776        })
777        .db_err()?;
778
779    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
780}
781
782/// Count messages in a session.
783pub fn message_count(db: &Database, session_id: &str) -> Result<i64> {
784    let conn = db.conn();
785    conn.query_row(
786        "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1",
787        [session_id],
788        |row| row.get(0),
789    )
790    .db_err()
791}
792
793// ── Retention pruning for high-write tables ───────────────────
794
795/// Delete turns, tool_calls (via FK cascade), session_messages, and
796/// inference_costs older than `retention_days` days.  Also prunes
797/// `model_selection_events` in the same window.
798///
799/// Returns the total number of rows deleted across all tables.
800pub fn prune_old_turn_data(db: &Database, retention_days: u32) -> Result<usize> {
801    let conn = db.conn();
802    let cutoff = format!("-{retention_days} days");
803
804    // tool_calls FK-cascades from turns, but session_messages doesn't.
805    // Delete children first, then parents.
806    let msgs = conn
807        .execute(
808            "DELETE FROM session_messages WHERE created_at < datetime('now', ?1)",
809            [&cutoff],
810        )
811        .unwrap_or(0);
812
813    let costs = conn
814        .execute(
815            "DELETE FROM inference_costs WHERE created_at < datetime('now', ?1)",
816            [&cutoff],
817        )
818        .unwrap_or(0);
819
820    let mse = conn
821        .execute(
822            "DELETE FROM model_selection_events WHERE created_at < datetime('now', ?1)",
823            [&cutoff],
824        )
825        .unwrap_or(0);
826
827    // tool_calls references turns(id), so delete tool_calls for old turns first.
828    let tc = conn
829        .execute(
830            "DELETE FROM tool_calls WHERE turn_id IN \
831             (SELECT id FROM turns WHERE created_at < datetime('now', ?1))",
832            [&cutoff],
833        )
834        .unwrap_or(0);
835
836    let turns = conn
837        .execute(
838            "DELETE FROM turns WHERE created_at < datetime('now', ?1)",
839            [&cutoff],
840        )
841        .unwrap_or(0);
842
843    Ok(msgs + costs + mse + tc + turns)
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    fn test_db() -> Database {
851        Database::new(":memory:").unwrap()
852    }
853
854    #[test]
855    fn find_or_create_returns_same_id() {
856        let db = test_db();
857        let id1 = find_or_create(&db, "agent-1", None).unwrap();
858        let id2 = find_or_create(&db, "agent-1", None).unwrap();
859        assert_eq!(id1, id2);
860    }
861
862    #[test]
863    fn concurrent_find_or_create_same_key_both_succeed() {
864        let db = std::sync::Arc::new(test_db());
865        let key = "concurrent-agent";
866        let (tx, rx) = std::sync::mpsc::channel();
867        let tx2 = tx.clone();
868        let th1 = {
869            let db = std::sync::Arc::clone(&db);
870            std::thread::spawn(move || {
871                let id = find_or_create(db.as_ref(), key, None).unwrap();
872                tx.send(id).unwrap();
873            })
874        };
875        let th2 = {
876            let db = std::sync::Arc::clone(&db);
877            std::thread::spawn(move || {
878                let id = find_or_create(db.as_ref(), key, None).unwrap();
879                tx2.send(id).unwrap();
880            })
881        };
882        let id1 = rx.recv().unwrap();
883        let id2 = rx.recv().unwrap();
884        th1.join().unwrap();
885        th2.join().unwrap();
886        assert_eq!(
887            id1, id2,
888            "concurrent find_or_create with same key should return same session id"
889        );
890    }
891
892    #[test]
893    fn get_session_returns_none_for_missing() {
894        let db = test_db();
895        let session = get_session(&db, "nonexistent").unwrap();
896        assert!(session.is_none());
897    }
898
899    #[test]
900    fn append_and_list_messages() {
901        let db = test_db();
902        let sid = find_or_create(&db, "agent-1", None).unwrap();
903        append_message(&db, &sid, "user", "hello").unwrap();
904        append_message(&db, &sid, "assistant", "hi there").unwrap();
905
906        let msgs = list_messages(&db, &sid, None).unwrap();
907        assert_eq!(msgs.len(), 2);
908        assert_eq!(msgs[0].role, "user");
909        assert_eq!(msgs[1].role, "assistant");
910    }
911
912    #[test]
913    fn get_session_after_create() {
914        let db = test_db();
915        let sid = find_or_create(&db, "agent-x", None).unwrap();
916        let session = get_session(&db, &sid)
917            .unwrap()
918            .expect("session should exist");
919        assert_eq!(session.agent_id, "agent-x");
920        assert_eq!(session.status, "active");
921        assert!(session.model.is_none());
922    }
923
924    #[test]
925    fn update_model_populates_field() {
926        let db = test_db();
927        let sid = find_or_create(&db, "agent-x", None).unwrap();
928        assert!(get_session(&db, &sid).unwrap().unwrap().model.is_none());
929
930        update_model(&db, &sid, "moonshot/kimi-k2-turbo-preview").unwrap();
931
932        let session = get_session(&db, &sid).unwrap().unwrap();
933        assert_eq!(
934            session.model.as_deref(),
935            Some("moonshot/kimi-k2-turbo-preview")
936        );
937    }
938
939    #[test]
940    fn list_messages_with_limit() {
941        let db = test_db();
942        let sid = find_or_create(&db, "agent-lim", None).unwrap();
943        for i in 0..10 {
944            append_message(&db, &sid, "user", &format!("msg-{i}")).unwrap();
945        }
946
947        let all = list_messages(&db, &sid, None).unwrap();
948        assert_eq!(all.len(), 10);
949
950        let limited = list_messages(&db, &sid, Some(3)).unwrap();
951        assert_eq!(limited.len(), 3);
952        assert_eq!(limited[0].content, "msg-0");
953    }
954
955    #[test]
956    fn update_metadata_roundtrip() {
957        let db = test_db();
958        let sid = find_or_create(&db, "agent-meta", None).unwrap();
959        update_metadata(&db, &sid, r#"{"topic":"testing"}"#).unwrap();
960
961        let session = get_session(&db, &sid).unwrap().unwrap();
962        assert_eq!(session.metadata.as_deref(), Some(r#"{"topic":"testing"}"#));
963    }
964
965    #[test]
966    fn update_metadata_missing_session() {
967        let db = test_db();
968        let result = update_metadata(&db, "nonexistent", "{}");
969        assert!(result.is_err());
970    }
971
972    #[test]
973    fn update_metadata_accepts_malformed_json() {
974        let db = test_db();
975        let sid = find_or_create(&db, "agent-meta", None).unwrap();
976        update_metadata(&db, &sid, "{invalid json blob").unwrap();
977        let session = get_session(&db, &sid).unwrap().unwrap();
978        assert_eq!(session.metadata.as_deref(), Some("{invalid json blob"));
979    }
980
981    #[test]
982    fn get_session_empty_id_returns_none() {
983        let db = test_db();
984        let session = get_session(&db, "").unwrap();
985        assert!(session.is_none());
986    }
987
988    #[test]
989    fn append_message_very_long_content() {
990        let db = test_db();
991        let sid = find_or_create(&db, "agent-long", None).unwrap();
992        let long = "x".repeat(100_000);
993        let id = append_message(&db, &sid, "user", &long).unwrap();
994        assert!(!id.is_empty());
995        let msgs = list_messages(&db, &sid, Some(1)).unwrap();
996        assert_eq!(msgs.len(), 1);
997        assert_eq!(msgs[0].content.len(), 100_000);
998    }
999
1000    #[test]
1001    fn create_turn_all_fields() {
1002        let db = test_db();
1003        let sid = find_or_create(&db, "agent-turn", None).unwrap();
1004        let turn_id =
1005            create_turn(&db, &sid, Some("gpt-4"), Some(100), Some(200), Some(0.03)).unwrap();
1006        assert!(!turn_id.is_empty());
1007    }
1008
1009    #[test]
1010    fn create_turn_all_none() {
1011        let db = test_db();
1012        let sid = find_or_create(&db, "agent-turn-none", None).unwrap();
1013        let turn_id = create_turn(&db, &sid, None, None, None, None).unwrap();
1014        assert!(!turn_id.is_empty());
1015    }
1016
1017    #[test]
1018    fn create_turn_multiple_per_session() {
1019        let db = test_db();
1020        let sid = find_or_create(&db, "agent-multi-turn", None).unwrap();
1021        let t1 = create_turn(&db, &sid, Some("gpt-4"), Some(10), Some(20), None).unwrap();
1022        let t2 = create_turn(&db, &sid, Some("gpt-4"), Some(30), Some(40), None).unwrap();
1023        assert_ne!(t1, t2);
1024    }
1025
1026    #[test]
1027    fn find_or_create_different_agents_different_sessions() {
1028        let db = test_db();
1029        let id1 = find_or_create(&db, "agent-a", None).unwrap();
1030        let id2 = find_or_create(&db, "agent-b", None).unwrap();
1031        assert_ne!(id1, id2);
1032    }
1033
1034    #[test]
1035    fn list_messages_nonexistent_session() {
1036        let db = test_db();
1037        let msgs = list_messages(&db, "no-such-session", None).unwrap();
1038        assert!(msgs.is_empty());
1039    }
1040
1041    #[test]
1042    fn list_messages_ordering_is_chronological() {
1043        let db = test_db();
1044        let sid = find_or_create(&db, "agent-order", None).unwrap();
1045        append_message(&db, &sid, "user", "first").unwrap();
1046        append_message(&db, &sid, "assistant", "second").unwrap();
1047        append_message(&db, &sid, "user", "third").unwrap();
1048
1049        let msgs = list_messages(&db, &sid, None).unwrap();
1050        assert_eq!(msgs.len(), 3);
1051        assert_eq!(msgs[0].content, "first");
1052        assert_eq!(msgs[1].content, "second");
1053        assert_eq!(msgs[2].content, "third");
1054    }
1055
1056    #[test]
1057    fn message_fields_populated() {
1058        let db = test_db();
1059        let sid = find_or_create(&db, "agent-fields", None).unwrap();
1060        let msg_id = append_message(&db, &sid, "user", "hello").unwrap();
1061        let msgs = list_messages(&db, &sid, Some(1)).unwrap();
1062        assert_eq!(msgs[0].id, msg_id);
1063        assert_eq!(msgs[0].session_id, sid);
1064        assert_eq!(msgs[0].role, "user");
1065        assert_eq!(msgs[0].content, "hello");
1066    }
1067
1068    // ── Session scoping tests ──
1069
1070    #[test]
1071    fn scope_key_agent() {
1072        let scope = SessionScope::Agent;
1073        assert_eq!(scope.scope_key(), "agent");
1074    }
1075
1076    #[test]
1077    fn scope_key_peer() {
1078        let scope = SessionScope::Peer {
1079            peer_id: "user123".into(),
1080            channel: "telegram".into(),
1081        };
1082        assert_eq!(scope.scope_key(), "peer:telegram:user123");
1083    }
1084
1085    #[test]
1086    fn scope_key_group() {
1087        let scope = SessionScope::Group {
1088            group_id: "grp-42".into(),
1089            channel: "discord".into(),
1090        };
1091        assert_eq!(scope.scope_key(), "group:discord:grp-42");
1092    }
1093
1094    #[test]
1095    fn find_or_create_with_peer_scope() {
1096        let db = test_db();
1097        let scope = SessionScope::Peer {
1098            peer_id: "alice".into(),
1099            channel: "telegram".into(),
1100        };
1101        let id1 = find_or_create(&db, "agent-1", Some(&scope)).unwrap();
1102        let id2 = find_or_create(&db, "agent-1", Some(&scope)).unwrap();
1103        assert_eq!(id1, id2);
1104
1105        let id_no_scope = find_or_create(&db, "agent-1", None).unwrap();
1106        assert_ne!(
1107            id1, id_no_scope,
1108            "scoped and unscoped sessions should differ"
1109        );
1110    }
1111
1112    #[test]
1113    fn find_or_create_different_scopes_different_sessions() {
1114        let db = test_db();
1115        let scope_a = SessionScope::Peer {
1116            peer_id: "alice".into(),
1117            channel: "telegram".into(),
1118        };
1119        let scope_b = SessionScope::Peer {
1120            peer_id: "bob".into(),
1121            channel: "telegram".into(),
1122        };
1123        let id_a = find_or_create(&db, "agent-1", Some(&scope_a)).unwrap();
1124        let id_b = find_or_create(&db, "agent-1", Some(&scope_b)).unwrap();
1125        assert_ne!(id_a, id_b);
1126    }
1127
1128    #[test]
1129    fn archive_session_sets_status() {
1130        let db = test_db();
1131        let sid = find_or_create(&db, "agent-archive", None).unwrap();
1132        archive_session(&db, &sid).unwrap();
1133        let session = get_session(&db, &sid).unwrap().unwrap();
1134        assert_eq!(session.status, "archived");
1135    }
1136
1137    #[test]
1138    fn archive_session_not_found() {
1139        let db = test_db();
1140        let result = archive_session(&db, "nonexistent");
1141        assert!(result.is_err());
1142    }
1143
1144    #[test]
1145    fn find_or_create_skips_archived_sessions() {
1146        let db = test_db();
1147        let sid1 = find_or_create(&db, "agent-skip", None).unwrap();
1148        archive_session(&db, &sid1).unwrap();
1149        let sid2 = find_or_create(&db, "agent-skip", None).unwrap();
1150        assert_ne!(sid1, sid2, "should create new session after archiving");
1151    }
1152
1153    #[test]
1154    fn list_active_sessions_filters_correctly() {
1155        let db = test_db();
1156        let sid1 = find_or_create(&db, "agent-list", None).unwrap();
1157        let _sid2 = find_or_create(&db, "agent-list-2", None).unwrap();
1158        archive_session(&db, &sid1).unwrap();
1159
1160        let active = list_active_sessions(&db, Some("agent-list")).unwrap();
1161        assert!(active.is_empty());
1162
1163        let all_active = list_active_sessions(&db, None).unwrap();
1164        assert_eq!(all_active.len(), 1);
1165        assert_eq!(all_active[0].agent_id, "agent-list-2");
1166    }
1167
1168    #[test]
1169    fn rotate_agent_session_archives_previous_and_creates_new() {
1170        let db = test_db();
1171        let sid1 = create_new(&db, "agent-rotate", None).unwrap();
1172        let sid2 = rotate_agent_session(&db, "agent-rotate").unwrap();
1173        assert_ne!(sid1, sid2);
1174
1175        let old = get_session(&db, &sid1).unwrap().unwrap();
1176        assert_eq!(old.status, "archived");
1177
1178        let active = list_active_sessions(&db, Some("agent-rotate")).unwrap();
1179        assert_eq!(active.len(), 1);
1180        assert_eq!(active[0].id, sid2);
1181    }
1182
1183    #[test]
1184    fn session_scope_serde_roundtrip() {
1185        let scope = SessionScope::Peer {
1186            peer_id: "u1".into(),
1187            channel: "tg".into(),
1188        };
1189        let json = serde_json::to_string(&scope).unwrap();
1190        let back: SessionScope = serde_json::from_str(&json).unwrap();
1191        assert_eq!(scope, back);
1192    }
1193
1194    // ── derive_nickname tests ────────────────────────────────
1195
1196    #[test]
1197    fn derive_nickname_strips_greeting() {
1198        assert_eq!(
1199            derive_nickname("Hey can you help me with Rust?"),
1200            "Can you help me with Rust"
1201        );
1202    }
1203
1204    #[test]
1205    fn derive_nickname_strips_hello() {
1206        assert_eq!(
1207            derive_nickname("Hello, I need a database schema"),
1208            "I need a database schema"
1209        );
1210    }
1211
1212    #[test]
1213    fn derive_nickname_takes_first_sentence() {
1214        assert_eq!(
1215            derive_nickname("Fix the build. Also update deps."),
1216            "Fix the build"
1217        );
1218    }
1219
1220    #[test]
1221    fn derive_nickname_truncates_long() {
1222        let long = "a ".repeat(50);
1223        let nick = derive_nickname(&long);
1224        assert!(nick.len() <= 55, "nickname too long: {} chars", nick.len());
1225        assert!(nick.ends_with("..."));
1226    }
1227
1228    #[test]
1229    fn derive_nickname_empty_returns_untitled() {
1230        assert_eq!(derive_nickname(""), "Untitled");
1231        assert_eq!(derive_nickname("   "), "Untitled");
1232    }
1233
1234    #[test]
1235    fn derive_nickname_greeting_only() {
1236        assert_eq!(derive_nickname("hey"), "Hey");
1237    }
1238
1239    #[test]
1240    fn derive_nickname_capitalizes() {
1241        assert_eq!(
1242            derive_nickname("refactor the auth module"),
1243            "Refactor the auth module"
1244        );
1245    }
1246
1247    #[test]
1248    fn derive_nickname_question_mark() {
1249        assert_eq!(
1250            derive_nickname("what is the meaning of life?"),
1251            "What is the meaning of life"
1252        );
1253    }
1254
1255    #[test]
1256    fn derive_nickname_unicode() {
1257        let nick = derive_nickname("日本語のテスト");
1258        assert!(!nick.is_empty());
1259        assert_ne!(nick, "Untitled");
1260    }
1261
1262    #[test]
1263    fn derive_nickname_multibyte_at_boundary() {
1264        let msg = "日".repeat(30);
1265        let nick = derive_nickname(&msg);
1266        assert!(!nick.is_empty());
1267        assert!(nick.len() <= 55, "nickname too long: {}", nick.len());
1268    }
1269
1270    #[test]
1271    fn derive_nickname_emoji_boundary() {
1272        let msg = format!("{}problem", "🔥".repeat(20));
1273        let nick = derive_nickname(&msg);
1274        assert!(!nick.is_empty());
1275    }
1276
1277    // ── update_nickname tests ────────────────────────────────
1278
1279    #[test]
1280    fn update_nickname_roundtrip() {
1281        let db = test_db();
1282        let sid = find_or_create(&db, "agent-nick", None).unwrap();
1283        assert!(get_session(&db, &sid).unwrap().unwrap().nickname.is_none());
1284
1285        update_nickname(&db, &sid, "My Cool Session").unwrap();
1286
1287        let session = get_session(&db, &sid).unwrap().unwrap();
1288        assert_eq!(session.nickname.as_deref(), Some("My Cool Session"));
1289    }
1290
1291    #[test]
1292    fn update_nickname_missing_session() {
1293        let db = test_db();
1294        let result = update_nickname(&db, "nonexistent", "test");
1295        assert!(result.is_err());
1296    }
1297
1298    #[test]
1299    fn update_nickname_overwrite() {
1300        let db = test_db();
1301        let sid = find_or_create(&db, "agent-overwrite", None).unwrap();
1302        update_nickname(&db, &sid, "First").unwrap();
1303        update_nickname(&db, &sid, "Second").unwrap();
1304        let session = get_session(&db, &sid).unwrap().unwrap();
1305        assert_eq!(session.nickname.as_deref(), Some("Second"));
1306    }
1307
1308    // ── backfill_nicknames tests ─────────────────────────────
1309
1310    #[test]
1311    fn backfill_nicknames_sets_from_first_message() {
1312        let db = test_db();
1313        let sid = find_or_create(&db, "agent-bf", None).unwrap();
1314        append_message(&db, &sid, "user", "Help me debug this crash").unwrap();
1315
1316        let count = backfill_nicknames(&db).unwrap();
1317        assert_eq!(count, 1);
1318
1319        let session = get_session(&db, &sid).unwrap().unwrap();
1320        assert_eq!(session.nickname.as_deref(), Some("Debug this crash"));
1321    }
1322
1323    #[test]
1324    fn backfill_nicknames_untitled_for_empty_session() {
1325        let db = test_db();
1326        find_or_create(&db, "agent-empty-bf", None).unwrap();
1327
1328        let count = backfill_nicknames(&db).unwrap();
1329        assert_eq!(count, 1);
1330
1331        let conn = db.conn();
1332        let nick: String = conn
1333            .query_row(
1334                "SELECT nickname FROM sessions WHERE agent_id = 'agent-empty-bf'",
1335                [],
1336                |row| row.get(0),
1337            )
1338            .unwrap();
1339        assert_eq!(nick, "Untitled");
1340    }
1341
1342    #[test]
1343    fn backfill_nicknames_skips_already_set() {
1344        let db = test_db();
1345        let sid = find_or_create(&db, "agent-skip-bf", None).unwrap();
1346        update_nickname(&db, &sid, "Already Set").unwrap();
1347        append_message(&db, &sid, "user", "something else").unwrap();
1348
1349        let count = backfill_nicknames(&db).unwrap();
1350        assert_eq!(count, 0);
1351
1352        let session = get_session(&db, &sid).unwrap().unwrap();
1353        assert_eq!(session.nickname.as_deref(), Some("Already Set"));
1354    }
1355
1356    // ── message_count tests ─────────────────────────────────
1357
1358    #[test]
1359    fn message_count_empty() {
1360        let db = test_db();
1361        let sid = find_or_create(&db, "agent-count", None).unwrap();
1362        assert_eq!(message_count(&db, &sid).unwrap(), 0);
1363    }
1364
1365    #[test]
1366    fn message_count_after_append() {
1367        let db = test_db();
1368        let sid = find_or_create(&db, "agent-count2", None).unwrap();
1369        append_message(&db, &sid, "user", "a").unwrap();
1370        append_message(&db, &sid, "assistant", "b").unwrap();
1371        append_message(&db, &sid, "user", "c").unwrap();
1372        assert_eq!(message_count(&db, &sid).unwrap(), 3);
1373    }
1374
1375    // ── turn feedback tests ──────────────────────────────────
1376
1377    #[test]
1378    fn record_and_get_feedback() {
1379        let db = test_db();
1380        let sid = find_or_create(&db, "agent-fb", None).unwrap();
1381        let tid = create_turn(&db, &sid, Some("gpt-4"), Some(100), Some(50), Some(0.01)).unwrap();
1382
1383        let fb_id = record_feedback(&db, &tid, &sid, 4, "dashboard", Some("good")).unwrap();
1384        assert!(!fb_id.is_empty());
1385
1386        let fb = get_feedback(&db, &tid)
1387            .unwrap()
1388            .expect("feedback should exist");
1389        assert_eq!(fb.grade, 4);
1390        assert_eq!(fb.source, "dashboard");
1391        assert_eq!(fb.comment.as_deref(), Some("good"));
1392    }
1393
1394    #[test]
1395    fn get_feedback_returns_none_for_missing() {
1396        let db = test_db();
1397        assert!(get_feedback(&db, "nonexistent").unwrap().is_none());
1398    }
1399
1400    #[test]
1401    fn update_feedback_changes_grade() {
1402        let db = test_db();
1403        let sid = find_or_create(&db, "agent-fb-up", None).unwrap();
1404        let tid = create_turn(&db, &sid, None, None, None, None).unwrap();
1405        record_feedback(&db, &tid, &sid, 3, "dashboard", None).unwrap();
1406
1407        update_feedback(&db, &tid, 5, Some("revised")).unwrap();
1408
1409        let fb = get_feedback(&db, &tid).unwrap().unwrap();
1410        assert_eq!(fb.grade, 5);
1411        assert_eq!(fb.comment.as_deref(), Some("revised"));
1412    }
1413
1414    #[test]
1415    fn update_feedback_missing_returns_error() {
1416        let db = test_db();
1417        assert!(update_feedback(&db, "nonexistent", 3, None).is_err());
1418    }
1419
1420    #[test]
1421    fn list_session_feedback_returns_all() {
1422        let db = test_db();
1423        let sid = find_or_create(&db, "agent-fb-list", None).unwrap();
1424        let t1 = create_turn(&db, &sid, None, None, None, None).unwrap();
1425        let t2 = create_turn(&db, &sid, None, None, None, None).unwrap();
1426        record_feedback(&db, &t1, &sid, 4, "dashboard", None).unwrap();
1427        record_feedback(&db, &t2, &sid, 2, "dashboard", Some("bad")).unwrap();
1428
1429        let fbs = list_session_feedback(&db, &sid).unwrap();
1430        assert_eq!(fbs.len(), 2);
1431    }
1432
1433    #[test]
1434    fn list_session_feedback_empty() {
1435        let db = test_db();
1436        let fbs = list_session_feedback(&db, "nonexistent").unwrap();
1437        assert!(fbs.is_empty());
1438    }
1439
1440    #[test]
1441    fn list_messages_stable_when_created_at_ties() {
1442        let db = test_db();
1443        let sid = create_new(&db, "agent-stable", Some(&SessionScope::Agent)).unwrap();
1444        {
1445            let conn = db.conn();
1446            conn.execute(
1447                "INSERT INTO session_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, 'assistant', 'm1', '2026-01-01 00:00:00')",
1448                rusqlite::params!["msg-z", sid.clone()],
1449            )
1450            .unwrap();
1451            conn.execute(
1452                "INSERT INTO session_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, 'assistant', 'm2', '2026-01-01 00:00:00')",
1453                rusqlite::params!["msg-a", sid.clone()],
1454            )
1455            .unwrap();
1456        }
1457        let msgs = list_messages(&db, &sid, None).unwrap();
1458        assert_eq!(msgs.len(), 2);
1459        assert_eq!(msgs[0].id, "msg-z");
1460        assert_eq!(msgs[1].id, "msg-a");
1461    }
1462
1463    #[test]
1464    fn list_turns_stable_when_created_at_ties() {
1465        let db = test_db();
1466        let sid = create_new(&db, "agent-stable-turns", Some(&SessionScope::Agent)).unwrap();
1467        {
1468            let conn = db.conn();
1469            conn.execute(
1470                "INSERT INTO turns (id, session_id, thinking, created_at) VALUES (?1, ?2, 't1', '2026-01-01 00:00:00')",
1471                rusqlite::params!["turn-z", sid.clone()],
1472            )
1473            .unwrap();
1474            conn.execute(
1475                "INSERT INTO turns (id, session_id, thinking, created_at) VALUES (?1, ?2, 't2', '2026-01-01 00:00:00')",
1476                rusqlite::params!["turn-a", sid.clone()],
1477            )
1478            .unwrap();
1479        }
1480        let turns = list_turns_for_session(&db, &sid).unwrap();
1481        assert_eq!(turns.len(), 2);
1482        assert_eq!(turns[0].id, "turn-z");
1483        assert_eq!(turns[1].id, "turn-a");
1484    }
1485
1486    #[test]
1487    fn record_feedback_upserts_on_duplicate_turn() {
1488        let db = test_db();
1489        let sid = find_or_create(&db, "agent-fb-upsert", None).unwrap();
1490        let tid = create_turn(&db, &sid, Some("gpt-4"), Some(100), Some(50), Some(0.01)).unwrap();
1491
1492        record_feedback(&db, &tid, &sid, 3, "dashboard", Some("okay")).unwrap();
1493        let fb1 = get_feedback(&db, &tid).unwrap().unwrap();
1494        assert_eq!(fb1.grade, 3);
1495
1496        record_feedback(&db, &tid, &sid, 5, "api", Some("great")).unwrap();
1497        let fb2 = get_feedback(&db, &tid).unwrap().unwrap();
1498        assert_eq!(fb2.grade, 5, "grade should be updated by upsert");
1499        assert_eq!(fb2.source, "api", "source should be updated by upsert");
1500        assert_eq!(fb2.comment.as_deref(), Some("great"));
1501
1502        let all = list_session_feedback(&db, &sid).unwrap();
1503        assert_eq!(all.len(), 1, "upsert should not create duplicate rows");
1504    }
1505
1506    // ── SessionStatus Display and from_str_lossy tests ─────────────
1507
1508    #[test]
1509    fn session_status_display() {
1510        assert_eq!(SessionStatus::Active.to_string(), "active");
1511        assert_eq!(SessionStatus::Archived.to_string(), "archived");
1512        assert_eq!(SessionStatus::Expired.to_string(), "expired");
1513    }
1514
1515    #[test]
1516    fn session_status_from_str_lossy() {
1517        assert_eq!(
1518            SessionStatus::from_str_lossy("archived"),
1519            SessionStatus::Archived
1520        );
1521        assert_eq!(
1522            SessionStatus::from_str_lossy("expired"),
1523            SessionStatus::Expired
1524        );
1525        assert_eq!(
1526            SessionStatus::from_str_lossy("active"),
1527            SessionStatus::Active
1528        );
1529        assert_eq!(
1530            SessionStatus::from_str_lossy("unknown"),
1531            SessionStatus::Active
1532        );
1533        assert_eq!(SessionStatus::from_str_lossy(""), SessionStatus::Active);
1534    }
1535
1536    // ── MessageRole Display and from_str_lossy tests ─────────────
1537
1538    #[test]
1539    fn message_role_display() {
1540        assert_eq!(MessageRole::User.to_string(), "user");
1541        assert_eq!(MessageRole::Assistant.to_string(), "assistant");
1542        assert_eq!(MessageRole::System.to_string(), "system");
1543        assert_eq!(MessageRole::Tool.to_string(), "tool");
1544    }
1545
1546    #[test]
1547    fn message_role_from_str_lossy() {
1548        assert_eq!(
1549            MessageRole::from_str_lossy("assistant"),
1550            MessageRole::Assistant
1551        );
1552        assert_eq!(MessageRole::from_str_lossy("system"), MessageRole::System);
1553        assert_eq!(MessageRole::from_str_lossy("tool"), MessageRole::Tool);
1554        assert_eq!(MessageRole::from_str_lossy("user"), MessageRole::User);
1555        assert_eq!(MessageRole::from_str_lossy("unknown"), MessageRole::User);
1556        assert_eq!(MessageRole::from_str_lossy(""), MessageRole::User);
1557    }
1558
1559    // ── create_turn_with_id tests ────────────────────────────────
1560
1561    #[test]
1562    fn create_turn_with_id_roundtrip() {
1563        let db = test_db();
1564        let sid = find_or_create(&db, "agent-twid", None).unwrap();
1565        create_turn_with_id(
1566            &db,
1567            "custom-turn-1",
1568            &sid,
1569            Some("gpt-4"),
1570            Some(100),
1571            Some(200),
1572            Some(0.05),
1573        )
1574        .unwrap();
1575
1576        let turns = list_turns_for_session(&db, &sid).unwrap();
1577        assert_eq!(turns.len(), 1);
1578        assert_eq!(turns[0].id, "custom-turn-1");
1579        assert_eq!(turns[0].model.as_deref(), Some("gpt-4"));
1580        assert_eq!(turns[0].tokens_in, Some(100));
1581        assert_eq!(turns[0].tokens_out, Some(200));
1582    }
1583
1584    #[test]
1585    fn create_turn_with_id_none_fields() {
1586        let db = test_db();
1587        let sid = find_or_create(&db, "agent-twid2", None).unwrap();
1588        create_turn_with_id(&db, "custom-turn-2", &sid, None, None, None, None).unwrap();
1589
1590        let turns = list_turns_for_session(&db, &sid).unwrap();
1591        assert_eq!(turns.len(), 1);
1592        assert_eq!(turns[0].id, "custom-turn-2");
1593        assert!(turns[0].model.is_none());
1594    }
1595
1596    // ── expire_stale_sessions and list_stale_active_session_ids tests ──
1597
1598    #[test]
1599    fn expire_stale_sessions_with_fresh_sessions() {
1600        let db = test_db();
1601        let _sid = find_or_create(&db, "agent-fresh", None).unwrap();
1602        // With a very large max_age (1 year), nothing should expire
1603        let count = expire_stale_sessions(&db, 365 * 86400).unwrap();
1604        assert_eq!(count, 0);
1605    }
1606
1607    #[test]
1608    fn expire_stale_sessions_expires_old() {
1609        let db = test_db();
1610        let sid = find_or_create(&db, "agent-old", None).unwrap();
1611        // Artificially age the session by setting updated_at to a past date
1612        db.conn()
1613            .execute(
1614                "UPDATE sessions SET updated_at = datetime('now', '-2 days') WHERE id = ?1",
1615                [&sid],
1616            )
1617            .unwrap();
1618
1619        let count = expire_stale_sessions(&db, 60).unwrap(); // 60 seconds max age
1620        assert_eq!(count, 1);
1621
1622        let session = get_session(&db, &sid).unwrap().unwrap();
1623        assert_eq!(session.status, "expired");
1624    }
1625
1626    #[test]
1627    fn list_stale_active_session_ids_returns_stale() {
1628        let db = test_db();
1629        let sid = find_or_create(&db, "agent-stale", None).unwrap();
1630        db.conn()
1631            .execute(
1632                "UPDATE sessions SET updated_at = datetime('now', '-3 days') WHERE id = ?1",
1633                [&sid],
1634            )
1635            .unwrap();
1636
1637        let stale_ids = list_stale_active_session_ids(&db, 60).unwrap();
1638        assert_eq!(stale_ids.len(), 1);
1639        assert_eq!(stale_ids[0], sid);
1640    }
1641
1642    #[test]
1643    fn list_stale_active_session_ids_excludes_fresh() {
1644        let db = test_db();
1645        let _sid = find_or_create(&db, "agent-fresh2", None).unwrap();
1646        let stale_ids = list_stale_active_session_ids(&db, 365 * 86400).unwrap();
1647        assert!(stale_ids.is_empty());
1648    }
1649
1650    // ── set_session_status tests ─────────────────────────────────
1651
1652    #[test]
1653    fn set_session_status_active_to_archived() {
1654        let db = test_db();
1655        let sid = find_or_create(&db, "agent-setstatus", None).unwrap();
1656        set_session_status(&db, &sid, SessionStatus::Archived).unwrap();
1657        let session = get_session(&db, &sid).unwrap().unwrap();
1658        assert_eq!(session.status, "archived");
1659    }
1660
1661    #[test]
1662    fn set_session_status_active_to_expired() {
1663        let db = test_db();
1664        let sid = find_or_create(&db, "agent-setstatus2", None).unwrap();
1665        set_session_status(&db, &sid, SessionStatus::Expired).unwrap();
1666        let session = get_session(&db, &sid).unwrap().unwrap();
1667        assert_eq!(session.status, "expired");
1668    }
1669
1670    #[test]
1671    fn set_session_status_missing_session_errors() {
1672        let db = test_db();
1673        let result = set_session_status(&db, "nonexistent", SessionStatus::Archived);
1674        assert!(result.is_err());
1675    }
1676
1677    // ── get_turn_by_id tests ─────────────────────────────────────
1678
1679    #[test]
1680    fn get_turn_by_id_existing() {
1681        let db = test_db();
1682        let sid = find_or_create(&db, "agent-gtbi", None).unwrap();
1683        let tid = create_turn(&db, &sid, Some("gpt-4"), Some(50), Some(100), Some(0.02)).unwrap();
1684
1685        let turn = get_turn_by_id(&db, &tid)
1686            .unwrap()
1687            .expect("turn should exist");
1688        assert_eq!(turn.id, tid);
1689        assert_eq!(turn.session_id, sid);
1690        assert_eq!(turn.model.as_deref(), Some("gpt-4"));
1691        assert_eq!(turn.tokens_in, Some(50));
1692        assert_eq!(turn.tokens_out, Some(100));
1693    }
1694
1695    #[test]
1696    fn get_turn_by_id_nonexistent() {
1697        let db = test_db();
1698        let turn = get_turn_by_id(&db, "no-such-turn").unwrap();
1699        assert!(turn.is_none());
1700    }
1701
1702    #[test]
1703    fn get_turn_by_id_with_custom_id() {
1704        let db = test_db();
1705        let sid = find_or_create(&db, "agent-gtbi2", None).unwrap();
1706        create_turn_with_id(
1707            &db,
1708            "my-turn-id",
1709            &sid,
1710            Some("claude"),
1711            Some(10),
1712            Some(20),
1713            Some(0.01),
1714        )
1715        .unwrap();
1716
1717        let turn = get_turn_by_id(&db, "my-turn-id")
1718            .unwrap()
1719            .expect("turn should exist");
1720        assert_eq!(turn.id, "my-turn-id");
1721        assert_eq!(turn.model.as_deref(), Some("claude"));
1722    }
1723
1724    // ── list_turns_for_session tests ─────────────────────────────
1725
1726    #[test]
1727    fn list_turns_for_session_empty() {
1728        let db = test_db();
1729        let sid = find_or_create(&db, "agent-turns-empty", None).unwrap();
1730        let turns = list_turns_for_session(&db, &sid).unwrap();
1731        assert!(turns.is_empty());
1732    }
1733
1734    // ── property-based tests (v0.8.0 stabilization) ────────────────────
1735
1736    use proptest::prelude::*;
1737
1738    proptest! {
1739        #[test]
1740        fn proptest_derive_nickname_never_empty(input in "\\PC{1,200}") {
1741            let result = derive_nickname(&input);
1742            prop_assert!(!result.is_empty(), "nickname should never be empty for input: {:?}", input);
1743        }
1744
1745        #[test]
1746        fn proptest_derive_nickname_bounded_length(input in "\\PC{1,500}") {
1747            let result = derive_nickname(&input);
1748            // Max is 50 chars + 3 for "..." = 53, but multibyte chars can push it slightly.
1749            // The function uses char_boundary(50) + possible "..." so 60 is a safe upper bound.
1750            prop_assert!(result.len() <= 60, "nickname too long: {} chars for input: {:?}", result.len(), input);
1751        }
1752
1753        #[test]
1754        fn proptest_derive_nickname_first_char_uppercase(input in "[a-z][a-zA-Z ]{0,100}") {
1755            let result = derive_nickname(&input);
1756            if result != "Untitled" {
1757                let first_char = result.chars().next().unwrap();
1758                if first_char.is_alphabetic() {
1759                    prop_assert!(first_char.is_uppercase(),
1760                        "first char '{}' should be uppercase in result: {:?}", first_char, result);
1761                }
1762            }
1763        }
1764
1765        #[test]
1766        fn proptest_derive_nickname_strips_greeting_prefix(
1767            greeting in prop::sample::select(vec!["hey ", "hi ", "hello ", "yo "]),
1768            rest in "[a-zA-Z]{3,30}"
1769        ) {
1770            let input = format!("{}{}", greeting, rest);
1771            let result = derive_nickname(&input);
1772            // After stripping the greeting, the result should match the capitalized `rest`,
1773            // not the full original input (greeting+rest). We verify the greeting was stripped
1774            // by checking the result doesn't equal the full input's nickname derivation
1775            // when the greeting is NOT stripped.
1776            let expected_start = {
1777                let mut chars = rest.chars();
1778                match chars.next() {
1779                    Some(c) => c.to_uppercase().to_string() + chars.as_str(),
1780                    None => String::new(),
1781                }
1782            };
1783            prop_assert!(
1784                result.starts_with(&expected_start[..expected_start.len().min(result.len())]),
1785                "result {:?} should start with capitalized rest {:?}", result, expected_start
1786            );
1787        }
1788    }
1789}