Files
ImageApi/src/database/persona_dao.rs
T
Cameron CordesandClaude Opus 5.5 4ac574d4d2 feat(persona-chat): continue an SMS thread with a persona
Persona conversations can now be anchored on a message in an SMS thread.
Creating one snapshots the thread up to that message (via SMS-API) into the
row; every turn appends it to the persona prompt with guidance to talk like a
text thread rather than write an insight.

Anchored turns get four tools: get_thread_messages (page the thread),
look_at_message_image (local vision on an MMS photo), get_photos_near_time
(library photos with tags and people) and look_at_photo (local vision on a
library photo). The conversation list filters by contact_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:07:49 -04:00

968 lines
36 KiB
Rust

#![allow(dead_code)]
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use std::ops::DerefMut;
use std::sync::{Arc, Mutex};
use crate::database::models::{InsertPersona, Persona};
use crate::database::schema;
use crate::database::{DbError, DbErrorKind, connect};
use crate::otel::trace_db_call;
/// One row of the persona-chat transcript. Lives in
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
///
/// One persona conversation.
///
/// `turn_count` is the cumulative number of assistant turns on the active
/// branch — what the list screen shows as the length of a conversation. The
/// transcript itself is the `messages_json` blob, holding a serialized
/// `ChatHistoryStore` tree. `title` is a generated summary of the opening
/// exchange, empty until the first turn completes.
#[derive(Clone, Debug)]
pub struct PersonaChatRow {
pub conversation_id: String,
pub persona_id: String,
pub title: String,
pub messages_json: String,
pub turn_count: i32,
pub created_at: i64,
pub updated_at: i64,
/// The SMS thread position this conversation grew out of, for chats
/// started via "Continue with persona". None for an open chat.
pub anchor: Option<SmsAnchor>,
}
/// Where in an SMS thread an anchored persona conversation picks up.
///
/// `contact_id` / `message_id` are SMS-API ids. `context` is the rendered
/// snapshot of the thread leading up to the anchor, captured once at
/// creation and spliced into the system prompt on every turn.
#[derive(Clone, Debug, PartialEq)]
pub struct SmsAnchor {
pub contact_id: i64,
pub contact_name: String,
pub message_id: i64,
/// Unix seconds of the anchor message.
pub timestamp: i64,
pub context: String,
}
/// The column tuple every persona-chat read selects. Shared by the
/// single-row and list queries so their `select(...)` orders can never
/// drift apart.
type PersonaChatColumns = (
String,
String,
String,
String,
i32,
i64,
i64,
Option<i64>,
Option<String>,
Option<i64>,
Option<i64>,
Option<String>,
);
macro_rules! persona_chat_select {
() => {
(
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
anchor_contact_id,
anchor_contact_name,
anchor_message_id,
anchor_ts,
anchor_context,
)
};
}
/// Column tuple → `PersonaChatRow`.
fn persona_chat_row(
(
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
anchor_contact_id,
anchor_contact_name,
anchor_message_id,
anchor_ts,
anchor_context,
): PersonaChatColumns,
) -> PersonaChatRow {
// An anchor needs at least the thread and the message; the rest has a
// usable default if a row was written half-way by hand.
let anchor = match (anchor_contact_id, anchor_message_id) {
(Some(contact_id), Some(message_id)) => Some(SmsAnchor {
contact_id,
contact_name: anchor_contact_name.unwrap_or_default(),
message_id,
timestamp: anchor_ts.unwrap_or(0),
context: anchor_context.unwrap_or_default(),
}),
_ => None,
};
PersonaChatRow {
conversation_id,
persona_id,
title,
messages_json,
turn_count,
created_at,
updated_at,
anchor,
}
}
/// Patch shape for update_persona. None = leave field alone. Built-ins are
/// allowed to flip `include_all_memories` but should reject name/prompt
/// edits at the handler layer (built-in copy lives in the migration).
pub struct PersonaPatch {
pub name: Option<String>,
pub system_prompt: Option<String>,
pub include_all_memories: Option<bool>,
pub reviewed_only_facts: Option<bool>,
pub allow_agent_corrections: Option<bool>,
}
/// One row of a bulk migration upload. Fields named to match the JSON
/// shape the mobile client uploads (`POST /personas/migrate`).
pub struct ImportPersona {
pub persona_id: String,
pub name: String,
pub system_prompt: String,
pub is_built_in: bool,
pub created_at: i64,
}
pub trait PersonaDao: Sync + Send {
fn list_personas(&mut self, user_id: i32) -> Result<Vec<Persona>, DbError>;
fn get_persona(&mut self, user_id: i32, persona_id: &str) -> Result<Option<Persona>, DbError>;
fn create_persona(
&mut self,
user_id: i32,
persona_id: &str,
name: &str,
system_prompt: &str,
is_built_in: bool,
include_all_memories: bool,
) -> Result<Persona, DbError>;
fn update_persona(
&mut self,
user_id: i32,
persona_id: &str,
patch: PersonaPatch,
) -> Result<Option<Persona>, DbError>;
fn delete_persona(&mut self, user_id: i32, persona_id: &str) -> Result<bool, DbError>;
/// Idempotent bulk import. INSERT OR IGNORE on (user_id, persona_id)
/// — re-uploading the same set is a no-op. Returns the number of rows
/// actually inserted (skipped duplicates don't count).
fn bulk_import(&mut self, user_id: i32, personas: &[ImportPersona]) -> Result<usize, DbError>;
// ── Persona-chat (open chat with persona) persistence ───────────
//
// Keyed on an opaque `conversation_id` so one persona can hold several
// separate conversations. `user_id` is checked on every read and write:
// the id is a bearer token for a transcript, so ownership can never be
// assumed from the id alone.
/// Fetch one conversation. None when it doesn't exist or belongs to
/// another user — the two are deliberately indistinguishable to callers.
fn get_persona_chat(
&mut self,
user_id: i32,
conversation_id: &str,
) -> Result<Option<PersonaChatRow>, DbError>;
/// Every conversation this user has going, newest first. Backs the chat
/// list screen, so it returns the transcript blob too — the snippet is
/// derived from the tree rather than denormalized into its own column.
fn list_persona_chats(&mut self, user_id: i32) -> Result<Vec<PersonaChatRow>, DbError>;
/// Start a new conversation with a persona, returning its id. Several
/// conversations with the same persona are expected, so this never
/// reuses an existing row.
fn create_persona_chat(
&mut self,
user_id: i32,
persona_id: &str,
created_at: i64,
) -> Result<String, DbError>;
/// Start a new conversation anchored on a point in an SMS thread
/// ("Continue with persona"). Same as `create_persona_chat` plus the
/// anchor columns.
fn create_anchored_persona_chat(
&mut self,
user_id: i32,
persona_id: &str,
created_at: i64,
anchor: &SmsAnchor,
) -> Result<String, DbError>;
/// Replace a conversation's transcript. Called once per completed turn
/// with the full new `messages_json`; `turn_count` is the cumulative
/// assistant-turn count on the resulting active branch. Returns the
/// number of rows written — 0 means the conversation is gone or is not
/// this user's.
fn update_persona_chat(
&mut self,
user_id: i32,
conversation_id: &str,
messages_json: &str,
turn_count: i32,
updated_at: i64,
) -> Result<usize, DbError>;
/// Name a conversation. Written once, after the first turn produces
/// enough of an exchange to summarize.
fn set_persona_chat_title(
&mut self,
user_id: i32,
conversation_id: &str,
title: &str,
) -> Result<(), DbError>;
/// Delete a conversation outright. Backs both the list screen's delete
/// affordance and the reset endpoint.
fn delete_persona_chat(&mut self, user_id: i32, conversation_id: &str) -> Result<(), DbError>;
}
pub struct SqlitePersonaDao {
connection: Arc<Mutex<SqliteConnection>>,
}
impl Default for SqlitePersonaDao {
fn default() -> Self {
Self::new()
}
}
impl SqlitePersonaDao {
pub fn new() -> Self {
Self {
connection: Arc::new(Mutex::new(connect())),
}
}
pub fn from_connection(conn: Arc<Mutex<SqliteConnection>>) -> Self {
Self { connection: conn }
}
}
impl PersonaDao for SqlitePersonaDao {
fn list_personas(&mut self, uid: i32) -> Result<Vec<Persona>, DbError> {
trace_db_call("query", "list_personas", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
personas
.filter(user_id.eq(uid))
.order(created_at.asc())
.load::<Persona>(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn get_persona(&mut self, uid: i32, pid: &str) -> Result<Option<Persona>, DbError> {
trace_db_call("query", "get_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
personas
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid))
.first::<Persona>(conn.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn create_persona(
&mut self,
uid: i32,
pid: &str,
nm: &str,
prompt: &str,
builtin: bool,
include_all: bool,
) -> Result<Persona, DbError> {
trace_db_call("insert", "create_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
diesel::insert_into(personas)
.values(InsertPersona {
user_id: uid,
persona_id: pid,
name: nm,
system_prompt: prompt,
is_built_in: builtin,
include_all_memories: include_all,
created_at: now,
updated_at: now,
reviewed_only_facts: false,
allow_agent_corrections: false,
})
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
personas
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid))
.first::<Persona>(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn update_persona(
&mut self,
uid: i32,
pid: &str,
patch: PersonaPatch,
) -> Result<Option<Persona>, DbError> {
trace_db_call("update", "update_persona", |_span| {
use schema::personas::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
// Apply each field as its own UPDATE — keeps types simple
// (Diesel's tuple updates don't compose cleanly across optional
// columns) and matches the pattern already in use for entities
// (knowledge_dao.rs::update_entity).
if let Some(ref new_name) = patch.name {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((name.eq(new_name), updated_at.eq(now)))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update name error: {}", e))?;
}
if let Some(ref new_prompt) = patch.system_prompt {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((system_prompt.eq(new_prompt), updated_at.eq(now)))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update prompt error: {}", e))?;
}
if let Some(new_include_all) = patch.include_all_memories {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((include_all_memories.eq(new_include_all), updated_at.eq(now)))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update include_all error: {}", e))?;
}
if let Some(new_reviewed_only) = patch.reviewed_only_facts {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((
reviewed_only_facts.eq(new_reviewed_only),
updated_at.eq(now),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update reviewed_only_facts error: {}", e))?;
}
if let Some(new_allow_corrections) = patch.allow_agent_corrections {
diesel::update(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.set((
allow_agent_corrections.eq(new_allow_corrections),
updated_at.eq(now),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update allow_agent_corrections error: {}", e))?;
}
personas
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid))
.first::<Persona>(conn.deref_mut())
.optional()
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::UpdateError, e))
}
fn delete_persona(&mut self, uid: i32, pid: &str) -> Result<bool, DbError> {
trace_db_call("delete", "delete_persona", |_span| {
let mut conn = self.connection.lock().expect("PersonaDao lock");
// One transaction so a persona and its conversations can't get
// out of step. There is no FK between the two tables, and the
// chat list skips conversations whose persona is gone — so
// without this the transcripts would linger invisibly forever.
conn.deref_mut().transaction::<_, anyhow::Error, _>(|tx| {
{
use schema::persona_chat_conversations::dsl::*;
diesel::delete(
persona_chat_conversations
.filter(user_id.eq(uid))
.filter(persona_id.eq(pid)),
)
.execute(tx)
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
}
use schema::personas::dsl::*;
let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
.execute(tx)
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
Ok(n > 0)
})
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn bulk_import(&mut self, uid: i32, rows: &[ImportPersona]) -> Result<usize, DbError> {
trace_db_call("insert", "bulk_import_personas", |_span| {
let mut conn = self.connection.lock().expect("PersonaDao lock");
let now = chrono::Utc::now().timestamp_millis();
let mut inserted = 0usize;
// INSERT OR IGNORE on the (user_id, persona_id) UNIQUE so
// re-running migrate is a no-op for personas already on the
// server.
for p in rows {
let n = diesel::sql_query(
"INSERT OR IGNORE INTO personas (user_id, persona_id, name, system_prompt, \
is_built_in, include_all_memories, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, 0, ?, ?)",
)
.bind::<diesel::sql_types::Integer, _>(uid)
.bind::<diesel::sql_types::Text, _>(&p.persona_id)
.bind::<diesel::sql_types::Text, _>(&p.name)
.bind::<diesel::sql_types::Text, _>(&p.system_prompt)
.bind::<diesel::sql_types::Bool, _>(p.is_built_in)
.bind::<diesel::sql_types::BigInt, _>(p.created_at)
.bind::<diesel::sql_types::BigInt, _>(now)
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
inserted += n;
}
Ok(inserted)
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn get_persona_chat(&mut self, uid: i32, cid: &str) -> Result<Option<PersonaChatRow>, DbError> {
trace_db_call("query", "get_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations
.filter(conversation_id.eq(cid))
// Scoped by user as well as id: the id is a bearer token for
// someone's transcript, so it never grants access on its own.
.filter(user_id.eq(uid))
.select(persona_chat_select!())
.first::<PersonaChatColumns>(conn.deref_mut())
.optional()
.map(|opt| opt.map(persona_chat_row))
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn list_persona_chats(&mut self, uid: i32) -> Result<Vec<PersonaChatRow>, DbError> {
trace_db_call("query", "list_persona_chats", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
persona_chat_conversations
.filter(user_id.eq(uid))
.order(updated_at.desc())
.select(persona_chat_select!())
.load::<PersonaChatColumns>(conn.deref_mut())
.map(|rows| rows.into_iter().map(persona_chat_row).collect())
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn create_persona_chat(
&mut self,
uid: i32,
pid: &str,
created: i64,
) -> Result<String, DbError> {
trace_db_call("insert", "create_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let new_id = uuid::Uuid::new_v4().to_string();
diesel::insert_into(persona_chat_conversations)
.values((
conversation_id.eq(&new_id),
user_id.eq(uid),
persona_id.eq(pid),
title.eq(""),
// The empty tree; the first turn seeds it with the
// persona's system prompt.
messages_json.eq("[]"),
turn_count.eq(0),
created_at.eq(created),
updated_at.eq(created),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
Ok(new_id)
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn create_anchored_persona_chat(
&mut self,
uid: i32,
pid: &str,
created: i64,
anchor: &SmsAnchor,
) -> Result<String, DbError> {
trace_db_call("insert", "create_anchored_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
let new_id = uuid::Uuid::new_v4().to_string();
diesel::insert_into(persona_chat_conversations)
.values((
conversation_id.eq(&new_id),
user_id.eq(uid),
persona_id.eq(pid),
title.eq(""),
messages_json.eq("[]"),
turn_count.eq(0),
created_at.eq(created),
updated_at.eq(created),
anchor_contact_id.eq(Some(anchor.contact_id)),
anchor_contact_name.eq(Some(&anchor.contact_name)),
anchor_message_id.eq(Some(anchor.message_id)),
anchor_ts.eq(Some(anchor.timestamp)),
anchor_context.eq(Some(&anchor.context)),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
Ok(new_id)
})
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
}
fn update_persona_chat(
&mut self,
uid: i32,
cid: &str,
json: &str,
count: i32,
updated: i64,
) -> Result<usize, DbError> {
trace_db_call("update", "update_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.set((
messages_json.eq(json),
turn_count.eq(count),
updated_at.eq(updated),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn set_persona_chat_title(
&mut self,
uid: i32,
cid: &str,
new_title: &str,
) -> Result<(), DbError> {
trace_db_call("update", "set_persona_chat_title", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::update(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.set(title.eq(new_title))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn delete_persona_chat(&mut self, uid: i32, cid: &str) -> Result<(), DbError> {
trace_db_call("delete", "delete_persona_chat", |_span| {
use schema::persona_chat_conversations::dsl::*;
let mut conn = self.connection.lock().expect("PersonaDao lock");
diesel::delete(
persona_chat_conversations
.filter(conversation_id.eq(cid))
.filter(user_id.eq(uid)),
)
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
Ok(())
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::test::in_memory_db_connection;
fn dao_with_user(username: &str) -> (SqlitePersonaDao, i32) {
use crate::database::schema::users::dsl as u;
let conn = Arc::new(Mutex::new(in_memory_db_connection()));
diesel::insert_into(u::users)
.values((u::username.eq(username), u::password.eq("x")))
.execute(conn.lock().unwrap().deref_mut())
.unwrap();
let user_id: i32 = u::users
.filter(u::username.eq(username))
.select(u::id)
.first(conn.lock().unwrap().deref_mut())
.unwrap();
(SqlitePersonaDao::from_connection(conn), user_id)
}
#[test]
fn create_and_list_round_trip() {
let (mut dao, uid) = dao_with_user("alice");
// The migration seeds 3 built-ins for any existing user; alice
// was created post-migration so she starts empty.
let p = dao
.create_persona(uid, "custom-1", "Custom A", "prompt A", false, false)
.unwrap();
assert_eq!(p.persona_id, "custom-1");
assert_eq!(p.user_id, uid);
assert!(!p.is_built_in);
let list = dao.list_personas(uid).unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].persona_id, "custom-1");
}
#[test]
fn unique_constraint_blocks_duplicate_persona_id() {
let (mut dao, uid) = dao_with_user("bob");
dao.create_persona(uid, "x", "X", "p", false, false)
.unwrap();
let err = dao.create_persona(uid, "x", "X2", "p2", false, false);
assert!(
err.is_err(),
"second insert with same persona_id should fail"
);
}
#[test]
fn bulk_import_is_idempotent() {
let (mut dao, uid) = dao_with_user("carol");
let rows = vec![
ImportPersona {
persona_id: "custom-a".into(),
name: "A".into(),
system_prompt: "p1".into(),
is_built_in: false,
created_at: 1,
},
ImportPersona {
persona_id: "custom-b".into(),
name: "B".into(),
system_prompt: "p2".into(),
is_built_in: false,
created_at: 2,
},
];
let first = dao.bulk_import(uid, &rows).unwrap();
assert_eq!(first, 2);
let second = dao.bulk_import(uid, &rows).unwrap();
assert_eq!(second, 0, "re-import should insert nothing");
assert_eq!(dao.list_personas(uid).unwrap().len(), 2);
}
#[test]
fn dao_update_does_not_block_built_ins() {
// Documenting contract: the DAO is intentionally permissive —
// `update_persona` will apply name/system_prompt edits to ANY
// row, including built-ins. The guard against editing built-in
// identity (name + systemPrompt) lives in the HTTP handler
// (src/personas.rs::update_persona). If you find yourself
// wanting to add the guard here too, prefer that — defence in
// depth — but keep this test passing so anyone who removes
// the handler guard gets a failing call site, not silent data
// corruption.
let (mut dao, uid) = dao_with_user("eve");
dao.create_persona(uid, "default", "Default", "old", true, false)
.unwrap();
let updated = dao
.update_persona(
uid,
"default",
PersonaPatch {
name: Some("Renamed".into()),
system_prompt: Some("new prompt".into()),
include_all_memories: None,
reviewed_only_facts: None,
allow_agent_corrections: None,
},
)
.unwrap()
.unwrap();
assert_eq!(updated.name, "Renamed");
assert_eq!(updated.system_prompt, "new prompt");
assert!(
updated.is_built_in,
"is_built_in flag should be unchanged by patch"
);
}
#[test]
fn update_toggles_include_all_memories() {
let (mut dao, uid) = dao_with_user("dan");
dao.create_persona(uid, "j", "Journal", "p", true, false)
.unwrap();
let updated = dao
.update_persona(
uid,
"j",
PersonaPatch {
name: None,
system_prompt: None,
include_all_memories: Some(true),
reviewed_only_facts: None,
allow_agent_corrections: None,
},
)
.unwrap()
.unwrap();
assert!(updated.include_all_memories);
}
// ── Persona-chat DAO tests ─────────────────────────────────────
/// Second user, for the isolation tests.
fn second_user(dao: &SqlitePersonaDao, username: &str) -> i32 {
use crate::database::schema::users::dsl as u;
let conn = dao.connection.clone();
diesel::insert_into(u::users)
.values((u::username.eq(username), u::password.eq("x")))
.execute(conn.lock().unwrap().deref_mut())
.unwrap();
u::users
.filter(u::username.eq(username))
.select(u::id)
.first(conn.lock().unwrap().deref_mut())
.unwrap()
}
#[test]
fn persona_chat_get_returns_none_for_an_unknown_conversation() {
let (mut dao, uid) = dao_with_user("p1");
assert!(dao.get_persona_chat(uid, "no-such-id").unwrap().is_none());
}
#[test]
fn persona_chat_create_then_get_round_trip() {
let (mut dao, uid) = dao_with_user("p2");
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap();
assert_eq!(row.conversation_id, cid);
assert_eq!(row.persona_id, "journal");
assert_eq!(row.title, "", "unnamed until the first turn completes");
assert_eq!(row.turn_count, 0);
assert_eq!(row.created_at, 100);
assert_eq!(row.updated_at, 100);
}
#[test]
fn persona_chat_create_makes_a_separate_row_each_time() {
// The whole point of the conversation_id key: one persona, several
// independent threads.
let (mut dao, uid) = dao_with_user("p3");
let first = dao.create_persona_chat(uid, "journal", 100).unwrap();
let second = dao.create_persona_chat(uid, "journal", 200).unwrap();
assert_ne!(first, second);
assert_eq!(dao.list_persona_chats(uid).unwrap().len(), 2);
}
#[test]
fn persona_chat_update_replaces_the_transcript() {
let (mut dao, uid) = dao_with_user("p4");
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
let rows = dao.update_persona_chat(uid, &cid, "first", 1, 150).unwrap();
assert_eq!(rows, 1);
let rows = dao
.update_persona_chat(uid, &cid, "second", 2, 200)
.unwrap();
assert_eq!(rows, 1);
let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "second");
assert_eq!(row.turn_count, 2);
assert_eq!(row.updated_at, 200);
assert_eq!(row.created_at, 100, "creation time is not disturbed");
}
#[test]
fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() {
// The turn loop treats 0 as "the conversation went away mid-turn"
// rather than silently succeeding.
let (mut dao, uid) = dao_with_user("p5");
let rows = dao
.update_persona_chat(uid, "no-such-id", "x", 1, 1)
.unwrap();
assert_eq!(rows, 0);
}
#[test]
fn persona_chat_set_title_names_the_conversation() {
let (mut dao, uid) = dao_with_user("p6");
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
dao.set_persona_chat_title(uid, &cid, "June recap").unwrap();
let row = dao.get_persona_chat(uid, &cid).unwrap().unwrap();
assert_eq!(row.title, "June recap");
}
#[test]
fn persona_chat_list_orders_newest_first() {
let (mut dao, uid) = dao_with_user("p7");
let older = dao.create_persona_chat(uid, "journal", 100).unwrap();
let newer = dao.create_persona_chat(uid, "coach", 300).unwrap();
let middle = dao.create_persona_chat(uid, "default", 200).unwrap();
let ids: Vec<String> = dao
.list_persona_chats(uid)
.unwrap()
.into_iter()
.map(|r| r.conversation_id)
.collect();
assert_eq!(
ids,
vec![newer, middle, older],
"ordered by updated_at descending so the list screen needs no re-sort"
);
}
#[test]
fn persona_chat_anchor_round_trips_and_open_chats_have_none() {
let (mut dao, uid) = dao_with_user("p10");
let anchor = SmsAnchor {
contact_id: 42,
contact_name: "Sam".to_string(),
message_id: 9001,
timestamp: 1_700_000_000,
context: "[2023-11-14 22:13] Sam: see you there".to_string(),
};
let anchored = dao
.create_anchored_persona_chat(uid, "journal", 100, &anchor)
.unwrap();
let open = dao.create_persona_chat(uid, "journal", 200).unwrap();
let row = dao.get_persona_chat(uid, &anchored).unwrap().unwrap();
assert_eq!(row.anchor, Some(anchor));
let row = dao.get_persona_chat(uid, &open).unwrap().unwrap();
assert_eq!(row.anchor, None);
assert!(
dao.list_persona_chats(uid)
.unwrap()
.iter()
.any(|r| r.anchor.is_some()),
"the list query carries the anchor too"
);
}
#[test]
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
let (mut dao, uid) = dao_with_user("p8");
assert!(dao.list_persona_chats(uid).unwrap().is_empty());
}
#[test]
fn persona_chat_delete_removes_the_conversation() {
let (mut dao, uid) = dao_with_user("p9");
let cid = dao.create_persona_chat(uid, "journal", 100).unwrap();
dao.delete_persona_chat(uid, &cid).unwrap();
assert!(dao.get_persona_chat(uid, &cid).unwrap().is_none());
assert!(dao.list_persona_chats(uid).unwrap().is_empty());
}
#[test]
fn deleting_a_persona_takes_its_conversations_with_it() {
// No FK between the tables, and the chat list hides conversations
// whose persona is gone — so an orphan row would be an invisible,
// unreachable transcript sitting in the database forever.
let (mut dao, uid) = dao_with_user("p-cascade");
dao.create_persona(uid, "custom-1", "Custom", "prompt", false, false)
.unwrap();
dao.create_persona_chat(uid, "custom-1", 100).unwrap();
dao.create_persona_chat(uid, "custom-1", 200).unwrap();
let survivor = dao.create_persona_chat(uid, "other", 300).unwrap();
assert!(dao.delete_persona(uid, "custom-1").unwrap());
let remaining: Vec<String> = dao
.list_persona_chats(uid)
.unwrap()
.into_iter()
.map(|r| r.conversation_id)
.collect();
assert_eq!(
remaining,
vec![survivor],
"only the untouched persona's conversation survives"
);
}
#[test]
fn persona_chat_reads_and_writes_are_scoped_to_the_owner() {
// A conversation_id is a bearer token for someone's transcript, so
// holding one must not grant another user access to it.
let (mut dao, uid1) = dao_with_user("owner");
let uid2 = second_user(&dao, "intruder");
let cid = dao.create_persona_chat(uid1, "journal", 100).unwrap();
dao.update_persona_chat(uid1, &cid, "private", 1, 100)
.unwrap();
assert!(
dao.get_persona_chat(uid2, &cid).unwrap().is_none(),
"another user cannot read it"
);
assert_eq!(
dao.update_persona_chat(uid2, &cid, "tampered", 9, 999)
.unwrap(),
0,
"another user cannot write it"
);
dao.delete_persona_chat(uid2, &cid).unwrap();
let row = dao.get_persona_chat(uid1, &cid).unwrap().unwrap();
assert_eq!(row.messages_json, "private", "and cannot delete it");
assert!(dao.list_persona_chats(uid2).unwrap().is_empty());
}
}