An anchored conversation now has a mode. 'about' is the existing behaviour (a persona talks with the user about the thread). 'as_contact' has the model play the contact so the user can rehearse the conversation: its own prompt (stay in the contact's voice, don't invent facts, coach on 'out of character'), no persona prompt, and only the thread tools — no memory, library, calendar or other-thread access, and no knowledge writes. The shared persona/file-chat loop now refuses tool calls that weren't in the turn's catalog, so the gates hold even when a model names a tool it wasn't offered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1016 lines
37 KiB
Rust
1016 lines
37 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,
|
|
pub mode: AnchorMode,
|
|
}
|
|
|
|
/// How an anchored conversation relates to its SMS thread.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum AnchorMode {
|
|
/// A persona talks with the user about the thread.
|
|
#[default]
|
|
About,
|
|
/// The model plays the contact so the user can practise the
|
|
/// conversation.
|
|
AsContact,
|
|
}
|
|
|
|
impl AnchorMode {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
AnchorMode::About => "about",
|
|
AnchorMode::AsContact => "as_contact",
|
|
}
|
|
}
|
|
|
|
/// Stored value → mode. NULL and anything unrecognised read as `About`,
|
|
/// the behaviour rows had before the column existed.
|
|
pub fn from_column(value: Option<&str>) -> Self {
|
|
match value {
|
|
Some("as_contact") => AnchorMode::AsContact,
|
|
_ => AnchorMode::About,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
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,
|
|
anchor_mode,
|
|
)
|
|
};
|
|
}
|
|
|
|
/// 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,
|
|
anchor_mode,
|
|
): 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(),
|
|
mode: AnchorMode::from_column(anchor_mode.as_deref()),
|
|
}),
|
|
_ => 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)),
|
|
anchor_mode.eq(Some(anchor.mode.as_str())),
|
|
))
|
|
.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(),
|
|
mode: AnchorMode::AsContact,
|
|
};
|
|
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 anchor_mode_reads_null_and_unknown_as_about() {
|
|
assert_eq!(AnchorMode::from_column(None), AnchorMode::About);
|
|
assert_eq!(AnchorMode::from_column(Some("weird")), AnchorMode::About);
|
|
assert_eq!(AnchorMode::from_column(Some("about")), AnchorMode::About);
|
|
assert_eq!(
|
|
AnchorMode::from_column(Some(AnchorMode::AsContact.as_str())),
|
|
AnchorMode::AsContact
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|