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
108pub 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
168pub 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
183pub 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
283pub 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
303pub 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
323pub 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
340pub 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
357pub 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
374pub 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
385pub 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
429pub 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
489fn 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
501pub 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 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
571pub 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#[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#[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
782pub 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
793pub 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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(); 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 #[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 #[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 #[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 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 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 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}