//! Open chat with persona — file-anchored insight chat's sibling. //! //! The persona chat is the same agentic loop, but anchored on a //! `conversation_id` instead of `(library_id, file_path)`. A persona can //! hold any number of separate conversations; each is a branching //! transcript tree supporting rewind and fork, exactly like the file chat. //! //! The session reuses three building blocks from the file chat: //! 1. `InsightGenerator::build_tool_definitions` for the tool catalog //! (gated by the persona's `ToolGateOpts` — no `describe_photo`, no //! `get_file_tags`, etc., because there is no file). //! 2. `InsightChatService::run_streaming_agentic_loop_with_entry` for the //! actual turn loop. It already pushes events to a `TurnEntry` and //! handles cancellation; we just feed it a no-photo argument set. //! 3. The shared `TurnRegistry` — extended with a `scope` label so the //! SSE `turn_info` payload can distinguish the two. //! //! What this module owns: //! - The persona-scoped persistence DAO. //! - Pure helpers (prompt builder, validation gates, seed-message shape). //! - The session that wires the agent loop to the persona DAO + turn //! registry. //! - The HTTP handlers under ` /persona_chat/*`. use crate::error::ApiError; use actix_web::{HttpResponse, Responder, delete, get, post, web}; use anyhow::{Context, Result, anyhow, bail}; use chrono::Utc; use opentelemetry::trace::Status; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex as StdMutex}; use tokio::sync::Mutex as TokioMutex; use tracing::Instrument; use tracing_opentelemetry::OpenTelemetrySpanExt; use uuid::Uuid; use crate::ai::backend::{BackendKind, SamplingOverrides}; use crate::ai::handlers::ReplayQuery; use crate::ai::insight_chat::{ ChatStreamEvent, ContextFitter, DEFAULT_MAX_ITERATIONS, ForkInfo, max_chat_iterations, render_tree_path, }; use crate::ai::insight_generator::InsightGenerator; use crate::ai::llm_client::{ChatHistoryStore, ChatMessage, StoredChatNode}; use crate::ai::turn_registry::{TurnEntry, TurnRegistry}; use crate::data::Claims; use crate::database::PersonaDao; use crate::libraries; use crate::state::AppState; // ───────────────────────────────────────────────────────────────────── // Errors // ───────────────────────────────────────────────────────────────────── #[derive(Debug)] pub enum PersonaChatError { UnknownPersona(String), UnknownConversation(String), EmptyMessage, MessageTooLong, ConcurrentTurn, /// "Continue with persona" couldn't read the SMS thread it was asked to /// anchor on — SMS-API down, or the message isn't in that thread. SmsThread(String), Db(anyhow::Error), } impl std::fmt::Display for PersonaChatError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { PersonaChatError::UnknownPersona(p) => write!(f, "persona '{p}' not found"), PersonaChatError::UnknownConversation(c) => { write!(f, "conversation '{c}' not found") } PersonaChatError::EmptyMessage => write!(f, "user_message must not be empty"), PersonaChatError::MessageTooLong => write!(f, "user_message exceeds 8192 chars"), PersonaChatError::ConcurrentTurn => { write!(f, "another turn for this persona is in flight") } PersonaChatError::SmsThread(e) => write!(f, "could not read the SMS thread: {e}"), PersonaChatError::Db(e) => write!(f, "database error: {e}"), } } } impl std::error::Error for PersonaChatError {} impl From<&PersonaChatError> for ApiError { fn from(e: &PersonaChatError) -> Self { match e { PersonaChatError::UnknownPersona(_) => { ApiError::not_found("persona_not_found", "That persona no longer exists") } PersonaChatError::UnknownConversation(_) => ApiError::not_found( "conversation_not_found", "That conversation no longer exists", ), PersonaChatError::EmptyMessage => { ApiError::bad_request("empty_message", "Type a message first") } PersonaChatError::MessageTooLong => ApiError::bad_request( "message_too_long", "That message is too long (8192 characters at most)", ), PersonaChatError::ConcurrentTurn => ApiError::conflict( "turn_in_progress", "A reply is still being written. Wait for it to finish.", ), PersonaChatError::SmsThread(detail) => { log::warn!("persona chat: SMS thread unreadable: {detail}"); ApiError::bad_gateway( "sms_thread_unavailable", "Couldn't read that message thread", ) } PersonaChatError::Db(err) => ApiError::internal(err), } } } impl From for PersonaChatError { fn from(e: anyhow::Error) -> Self { PersonaChatError::Db(e) } } // ───────────────────────────────────────────────────────────────────── // Pure helpers — covered by unit tests at the bottom of this file. // ───────────────────────────────────────────────────────────────────── /// Resolve the system prompt for a persona chat turn. /// /// In the v1 scope there's no per-turn persona override: the persona's /// stored `systemPrompt` is authoritative. A future "voice override" can /// extend this signature without touching the call sites. pub fn resolve_persona_system_prompt( persona: Option<&crate::database::models::Persona>, ) -> Option { persona .filter(|p| !p.system_prompt.trim().is_empty()) .map(|p| p.system_prompt.clone()) } /// Build the seed messages for a fresh persona conversation. /// /// Just the system prompt. The file chat seeds a synthetic "describe this /// photo" user turn because its transcript is anchored on an image; an open /// chat has no anchor, so the user's own first question is message one. /// /// v1 also seeded an assistant greeting here. It never reached the screen — /// the flat renderer stripped it — and the tree renderer has no equivalent /// stripping pass, so it is gone. `drop_seed_greeting` handles rows that /// still carry one. pub fn seed_messages_for_persona(system_prompt: &str) -> Vec { vec![ChatMessage::system(system_prompt.to_string())] } /// The persona id a practice conversation is stored under. Practice mode /// plays the SMS contact, not a persona, so the conversation belongs to no /// persona row: deleting or editing personas can't hide or break it. pub const PRACTICE_PERSONA_ID: &str = ""; /// Name shown for a practice conversation where a persona name would go. pub const PRACTICE_PERSONA_NAME: &str = "Practice"; /// Whether a stored conversation is a practice one (plays the contact). fn is_practice(row: &crate::database::persona_dao::PersonaChatRow) -> bool { row.anchor .as_ref() .is_some_and(|a| a.mode == crate::database::AnchorMode::AsContact) } /// The stand-in persona a practice turn runs with. Its prompt is never used /// (`anchored_system_prompt` ignores the persona in practice mode) and it /// can't correct facts — practice is offered no knowledge tools at all. fn practice_persona(user_id: i32) -> crate::database::models::Persona { crate::database::models::Persona { id: 0, user_id, persona_id: PRACTICE_PERSONA_ID.to_string(), name: PRACTICE_PERSONA_NAME.to_string(), system_prompt: String::new(), is_built_in: false, include_all_memories: false, created_at: 0, updated_at: 0, reviewed_only_facts: false, allow_agent_corrections: false, } } /// Parse a persisted transcript into the conversation tree. /// /// Two on-disk formats are accepted, mirroring the file chat's reader: /// 1. `ChatHistoryStore` JSON — the tree, which is what every write produces /// now. /// 2. A flat `Vec` — rows written before the tree migration, /// upgraded on read into a single linear branch. pub fn decode_store(raw: &str) -> Result { if let Ok(flat) = serde_json::from_str::>(raw) { return Ok(ChatHistoryStore::from_flat_array(drop_seed_greeting(flat))); } serde_json::from_str(raw).with_context(|| "failed to deserialize persona chat history") } pub fn encode_store(store: &ChatHistoryStore) -> Result { serde_json::to_string(store).with_context(|| "failed to serialize persona chat history") } /// Drop the v1 seed greeting from a pre-migration transcript. /// /// The greeting was invisible under the flat renderer, which stripped every /// leading system/greeting message. The tree renderer skips `system` nodes /// but renders every assistant node, so without this the upgrade would /// surface a bubble the user has never seen. Scoped to an assistant message /// preceded only by system messages, so a genuine reply that happens to open /// with the same words is left alone. fn drop_seed_greeting(mut messages: Vec) -> Vec { if let Some(idx) = messages.iter().position(|m| m.role != "system") && messages[idx].role == "assistant" && is_seed_greeting(&messages[idx].content) { messages.remove(idx); } messages } fn is_seed_greeting(content: &str) -> bool { content.starts_with("Hi — I'm") && content.contains("ready to help") } // ───────────────────────────────────────────────────────────────────── // Wire shapes — snake_case, matching the file-chat convention. // ───────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub struct PersonaChatTurnRequest { /// Conversation to append to. The persona is read from the stored /// conversation, not the request — a transcript's voice is fixed when it /// is created, so a stale client can't quietly swap it mid-thread. pub conversation_id: String, /// Free-text user message. Trimmed before validation; empty input is a 400. pub user_message: String, #[serde(default)] pub model: Option, #[serde(default)] pub backend: Option, #[serde(default)] pub num_ctx: Option, #[serde(default)] pub temperature: Option, #[serde(default)] pub top_p: Option, #[serde(default)] pub top_k: Option, #[serde(default)] pub min_p: Option, #[serde(default)] pub enable_thinking: Option, /// Per-turn system-prompt override. Appended to the persona's prompt /// for this turn only; not persisted (persona voice survives a /// voice-flipped turn). Empty / whitespace = no override. #[serde(default)] pub system_prompt: Option, #[serde(default)] pub library: Option, } #[derive(Debug, Serialize)] pub struct PersonaChatHistoryView { /// Conversation this transcript belongs to. Empty for the empty /// envelope returned when the conversation has no messages yet. pub conversation_id: String, /// Persona whose voice this conversation uses. pub persona_id: String, /// Generated name for the conversation; empty until the first turn has /// produced enough of an exchange to summarize. pub title: String, /// Rendered transcript — same message shape the file chat's history /// endpoint ships, so the client's renderer is shared. pub messages: Vec, pub turn_count: u32, pub model_version: String, pub backend: String, /// Leaf the conversation is anchored to. A new turn extends this leaf. pub active_leaf_id: u64, /// Leaf actually being rendered — equals `active_leaf_id` unless the /// client asked for an alternate branch via `branch_id`. pub viewing_branch_id: u64, /// Per-message divergence markers, `None` where the path doesn't fork. /// Empty for a conversation that has never been rewound. #[serde(skip_serializing_if = "Vec::is_empty")] pub fork_info: Vec>, /// Where in an SMS thread this conversation picked up. Absent for an /// open chat. #[serde(skip_serializing_if = "Option::is_none")] pub anchor: Option, } /// Wire shape of a conversation's SMS anchor. Enough for the client to show /// a "picked up from" banner and to jump back to the message in the thread; /// the full snapshot stays server-side in the system prompt. #[derive(Debug, Clone, Serialize)] pub struct PersonaChatAnchorView { pub contact_id: i64, pub contact_name: String, pub message_id: i64, /// Unix seconds of the anchor message. pub timestamp: i64, /// The anchor message as "Speaker: text", recovered from the snapshot. pub preview: String, /// `about` (a persona discusses the thread) or `as_contact` (the model /// plays the contact for practice). pub mode: crate::database::AnchorMode, } impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView { fn from(a: &crate::database::SmsAnchor) -> Self { PersonaChatAnchorView { contact_id: a.contact_id, contact_name: a.contact_name.clone(), message_id: a.message_id, timestamp: a.timestamp, preview: crate::ai::sms_anchor::anchor_preview(&a.context), mode: a.mode, } } } /// One rendered line of the persona transcript. Mirrors the file chat's /// `RenderedHistoryMessage` wire shape: `tools` carries the tool /// invocations that led to the assistant reply (empty for user turns). #[derive(Debug, Serialize)] pub struct RenderedPersonaMessage { pub role: String, pub content: String, pub is_initial: bool, #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, } /// Render a branch path into the persona wire shape. /// /// A thin wrapper over the file chat's `render_tree_path` so both surfaces /// fold tool scaffolding, detect divergences and rank branches identically. /// The one persona-specific adjustment is `is_initial`: the file chat's /// first rendered message is the synthetic "describe this photo" prompt, /// which must never be rewound or regenerated, whereas a persona chat's /// first message is the user's own question and both actions are legitimate /// on it. fn render_persona_path( store: &ChatHistoryStore, path: &[&StoredChatNode], ) -> ( Vec, usize, Vec, Vec>, ) { let (rendered, turn_count, node_ids, fork_info) = render_tree_path(store, path); let rendered = rendered .into_iter() .map(|m| RenderedPersonaMessage { role: m.role, content: m.content, is_initial: false, tools: m .tools .into_iter() .map(|t| crate::ai::handlers::HistoryToolInvocation { name: t.name, arguments: t.arguments, result: t.result, result_truncated: t.result_truncated, }) .collect(), }) .collect(); (rendered, turn_count, node_ids, fork_info) } /// The node a rewind should re-anchor `active_leaf_id` on. /// /// `node_ids[i]` is the tree node behind rendered message `i`, so keeping /// everything before index `i` means anchoring on `node_ids[i - 1]`. Index 0 /// discards the whole transcript, which anchors on the parent of the first /// rendered node — the seed system node — so that a resend forks there and /// the discarded path stays reachable via the chip on the new first bubble. fn rewind_target_leaf( store: &ChatHistoryStore, node_ids: &[u64], discard_from_rendered_index: usize, ) -> Result { if discard_from_rendered_index == 0 { let first = *node_ids .first() .ok_or_else(|| anyhow!("discard_from_rendered_index out of range"))?; return store .parent_of(first) .map(|n| n.id) .ok_or_else(|| anyhow!("cannot rewind past the start of the conversation")); } node_ids .get(discard_from_rendered_index - 1) .copied() .ok_or_else(|| anyhow!("discard_from_rendered_index out of range")) } /// Hard cap on a generated conversation title. Long enough to be /// descriptive, short enough to fit a nav bar and a list row heading. const TITLE_MAX_CHARS: usize = 48; /// Clean up whatever the model returned into a usable title. /// /// Small models are chatty about this task: they wrap titles in quotes, /// prefix them with "Title:", tack on a trailing period, or return a whole /// sentence. Everything here is defensive against that, and anything still /// too long is truncated on a character boundary. pub fn sanitize_title(raw: &str) -> String { let mut text = raw.trim(); // Some models answer with a preamble line then the title; take the first // non-empty line and drop the rest. if let Some(line) = text.lines().map(str::trim).find(|l| !l.is_empty()) { text = line; } let lowered = text.to_lowercase(); for prefix in ["title:", "conversation title:", "chat title:"] { if lowered.starts_with(prefix) { text = text[prefix.len()..].trim(); break; } } let text = text .trim_matches(|c: char| c == '"' || c == '\'' || c == '“' || c == '”' || c == '*') .trim() .trim_end_matches('.') .trim(); let flattened = text.split_whitespace().collect::>().join(" "); if flattened.chars().count() <= TITLE_MAX_CHARS { return flattened; } let truncated: String = flattened.chars().take(TITLE_MAX_CHARS).collect(); format!("{}…", truncated.trim_end()) } /// Title to use when the model can't be reached or returns nothing usable. /// /// The user's opening question is a better name than "Untitled": it is /// exactly the thing they would scan the list for. pub fn fallback_title(messages: &[ChatMessage]) -> String { messages .iter() .find(|m| m.role == "user") .map(|m| sanitize_title(&m.content)) .unwrap_or_default() } /// The prompt used to name a conversation from its opening exchange. pub fn title_prompt(user_message: &str, assistant_reply: &str) -> String { format!( "Summarize this conversation opening as a short title of at most six \ words. Reply with the title alone — no quotes, no punctuation at the \ end, no preamble.\n\nUser: {}\n\nAssistant: {}", conversation_snippet(user_message), conversation_snippet(assistant_reply), ) } /// Soft cap for the preview line on the chat list. Long enough to tell two /// conversations apart, short enough that a row stays two lines on a phone. const SNIPPET_MAX_CHARS: usize = 140; /// Collapse a message into a single-line preview for the chat list. /// /// Newlines become spaces so a markdown reply doesn't blow the row height, /// and the cut is taken on a character boundary — slicing a UTF-8 string by /// byte index panics the moment a reply contains an emoji or an accent. pub fn conversation_snippet(content: &str) -> String { let flattened = content.split_whitespace().collect::>().join(" "); if flattened.chars().count() <= SNIPPET_MAX_CHARS { return flattened; } let truncated: String = flattened.chars().take(SNIPPET_MAX_CHARS).collect(); format!("{}…", truncated.trim_end()) } /// Map the internal fork markers onto the serialized wire type shared with /// the file chat's history response. fn wire_fork_info( fork_info: Vec>, ) -> Vec> { fork_info .into_iter() .map(|f| { f.map(|fi| crate::ai::handlers::ChatForkInfo { position: fi.position, total: fi.total, node_id: fi.node_id, }) }) .collect() } /// One row of the persona chat list: enough to render a conversation card /// without shipping the whole transcript. #[derive(Debug, Serialize)] pub struct PersonaConversationSummary { pub conversation_id: String, pub persona_id: String, pub persona_name: String, /// Generated name for the conversation. Empty until the first turn /// completes; the client falls back to the persona name. pub title: String, /// Last rendered message on the active branch, flattened to one line. pub snippet: String, /// Role behind `snippet`, so the list can prefix the user's own last /// word with "You:". pub snippet_role: String, /// Assistant turns on the active branch. pub turn_count: u32, /// Rendered messages on the active branch (user + assistant bubbles). pub message_count: usize, pub created_at: i64, pub updated_at: i64, /// Whether the conversation has been forked — the list marks these so a /// user can tell which transcripts have alternates parked in them. pub has_branches: bool, /// SMS anchor for "Continue with persona" chats; absent for open chats. #[serde(skip_serializing_if = "Option::is_none")] pub anchor: Option, } impl PersonaChatHistoryView { pub fn empty() -> Self { Self { conversation_id: String::new(), persona_id: String::new(), title: String::new(), messages: Vec::new(), turn_count: 0, model_version: String::new(), backend: String::new(), active_leaf_id: 0, viewing_branch_id: 0, fork_info: Vec::new(), anchor: None, } } } // ───────────────────────────────────────────────────────────────────── // Per-persona in-flight mutex // ───────────────────────────────────────────────────────────────────── /// One async mutex per conversation — serializes concurrent writers to a /// single transcript (a turn appending, a rewind re-pointing the leaf) so /// the stored tree never interleaves two mutations. pub type PersonaLockMap = TokioMutex>>>; /// Tracks the turn in flight for each conversation so a second dispatch /// against the same conversation is rejected (HTTP 409) instead of racing /// the first. Keyed on `conversation_id` alone — ids are uuids, so they /// don't collide across users. #[derive(Default)] pub struct InFlightPersonaTurns { turns: StdMutex>, } impl InFlightPersonaTurns { pub fn new() -> Self { Self::default() } /// Claim `conversation_id` for `turn_id`. Fails with the id of the turn /// already holding the slot, which the handler reports so the client can /// re-attach to it rather than just seeing a bare conflict. pub fn claim(&self, conversation_id: &str, turn_id: &str) -> Result<(), String> { let mut map = self.turns.lock().expect("in-flight turns poisoned"); if let Some(existing) = map.get(conversation_id) { return Err(existing.clone()); } map.insert(conversation_id.to_string(), turn_id.to_string()); Ok(()) } pub fn release(&self, conversation_id: &str) { if let Ok(mut map) = self.turns.lock() { map.remove(conversation_id); } } } /// Frees the in-flight slot when the spawned turn task ends — on completion, /// error, or abort. struct InFlightGuard { in_flight: Arc, conversation_id: String, } impl Drop for InFlightGuard { fn drop(&mut self) { self.in_flight.release(&self.conversation_id); } } #[derive(Clone)] pub struct PersonaChatSession { generator: Arc, persona_dao: Arc>>, chat_locks: Arc, in_flight: Arc, } impl PersonaChatSession { pub fn new( generator: Arc, persona_dao: Arc>>, chat_locks: Arc, ) -> Self { Self { generator, persona_dao, chat_locks, in_flight: Arc::new(InFlightPersonaTurns::new()), } } /// Start a new conversation with a persona. /// /// Always a fresh row: a persona is expected to hold several separate /// conversations, so this never resumes an existing one. pub fn create_conversation( &self, user_id: i32, persona_id: &str, ) -> Result { let persona_id = persona_id.trim(); if persona_id.is_empty() { return Err(PersonaChatError::UnknownPersona(String::new())); } let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); // Validate up front rather than letting the first turn 404: a // conversation row pointing at a persona that doesn't exist would be // a permanently dead entry in the list. let persona = dao .get_persona(user_id, persona_id) .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?; if persona.is_none() { return Err(PersonaChatError::UnknownPersona(persona_id.to_string())); } dao.create_persona_chat(user_id, persona_id, Utc::now().timestamp_millis()) .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}"))) } /// Start a conversation anchored on a message in an SMS thread /// ("Continue with persona"). /// /// The thread leading up to the anchor is fetched once, here, and stored /// as a rendered snapshot: every turn splices it into the system prompt, /// so it has to be byte-stable (KV-cache prefix reuse) and has to /// exist before the first turn. A thread that can't be read fails the /// create outright — an anchored chat with no thread is just an open /// chat with a misleading banner. pub async fn create_anchored_conversation( &self, user_id: i32, persona_id: &str, contact_id: i64, message_id: i64, mode: crate::database::AnchorMode, ) -> Result { // Practice plays the contact; any persona the client sent is ignored. let persona_id = if mode == crate::database::AnchorMode::AsContact { PRACTICE_PERSONA_ID } else { persona_id.trim() }; if mode != crate::database::AnchorMode::AsContact { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); let persona = dao .get_persona(user_id, persona_id) .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?; if persona.is_none() { return Err(PersonaChatError::UnknownPersona(persona_id.to_string())); } } let window = self .generator .sms_client() .fetch_thread_window( contact_id, message_id, crate::ai::sms_anchor::ANCHOR_CONTEXT_BEFORE, 0, ) .await .map_err(|e| PersonaChatError::SmsThread(format!("{e}")))?; let context = crate::ai::sms_anchor::render_thread_window( &window, &crate::ai::user_display_name(), self.generator.display_zone(), ); let anchor = crate::database::SmsAnchor { contact_id, contact_name: window.contact.display_name(), message_id, timestamp: window.messages[window.anchor_index].timestamp, context, mode, }; let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); dao.create_anchored_persona_chat( user_id, persona_id, Utc::now().timestamp_millis(), &anchor, ) .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}"))) } /// Delete a conversation and its transcript outright. pub fn delete_conversation(&self, user_id: i32, conversation_id: &str) -> Result<()> { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); dao.delete_persona_chat(user_id, conversation_id) .map_err(|e| anyhow!("failed to delete persona chat: {e:?}"))?; Ok(()) } /// Load a transcript branch for one conversation. /// /// `branch_id` renders an alternate leaf without making it active, so /// the client can preview a fork before committing to it; `None` renders /// the active branch. Returns an empty envelope for a conversation that /// has been created but not yet spoken in. pub fn load_history( &self, user_id: i32, conversation_id: &str, branch_id: Option, ) -> Result { let row = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); dao.get_persona_chat(user_id, conversation_id) .map_err(|e| anyhow!("failed to load persona chat: {e:?}"))? }; let Some(row) = row else { bail!("conversation not found"); }; let store = decode_store(&row.messages_json)?; let identity = PersonaChatHistoryView { conversation_id: row.conversation_id.clone(), persona_id: row.persona_id.clone(), title: row.title.clone(), anchor: row.anchor.as_ref().map(PersonaChatAnchorView::from), ..PersonaChatHistoryView::empty() }; // A conversation holding only its seed system node renders as the // empty state, not as a zero-message transcript with a leaf id. if store.nodes.is_empty() { return Ok(identity); } let target_leaf = branch_id.unwrap_or(store.active_leaf_id); let path = store .path_to_leaf(target_leaf) .ok_or_else(|| anyhow!("branch_id {target_leaf} not found in tree"))?; let (messages, turn_count, _node_ids, fork_info) = render_persona_path(&store, &path); Ok(PersonaChatHistoryView { messages, // Assistant turns actually on this path — not the stored count, // which tracks the active branch. turn_count: turn_count as u32, active_leaf_id: store.active_leaf_id, viewing_branch_id: target_leaf, fork_info: wire_fork_info(fork_info), ..identity }) } /// Dispatch a new turn. Returns the `turn_id` immediately; the /// streaming agent loop runs in a Tokio task and pushes events to /// the registered `TurnEntry`. pub async fn dispatch_turn( self: Arc, registry: Arc, user_id: i32, library_id: i32, req: PersonaChatTurnRequest, ) -> Result { let trimmed = req.user_message.trim().to_string(); if trimmed.is_empty() { return Err(PersonaChatError::EmptyMessage); } if trimmed.len() > 8192 { return Err(PersonaChatError::MessageTooLong); } let conversation_id = req.conversation_id.trim().to_string(); if conversation_id.is_empty() { return Err(PersonaChatError::UnknownConversation(conversation_id)); } // The persona comes from the stored conversation, not the request: // a transcript's voice is fixed when it is created, so a stale // client can't quietly swap it half-way through a thread. let (persona, persona_id) = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); let row = dao .get_persona_chat(user_id, &conversation_id) .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))? .ok_or_else(|| PersonaChatError::UnknownConversation(conversation_id.clone()))?; let persona = if is_practice(&row) { practice_persona(user_id) } else { dao.get_persona(user_id, &row.persona_id) .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))? .ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))? }; (persona, row.persona_id) }; let turn_id = Uuid::new_v4().to_string(); self.in_flight .claim(&conversation_id, &turn_id) .map_err(|_| PersonaChatError::ConcurrentTurn)?; let entry = Arc::new(TurnEntry::new_persona( turn_id.clone(), persona_id.clone(), library_id, )); registry.insert(entry.clone()).await; let svc = self.clone(); let persona_for_task = persona.clone(); let entry_clone = entry.clone(); let req_for_task = PersonaChatTurnRequest { user_message: trimmed.clone(), ..req }; let in_flight_guard = InFlightGuard { in_flight: self.in_flight.clone(), conversation_id: conversation_id.clone(), }; // Created before the spawn so it parents on the dispatching request, // then carried into the task with `.instrument` (tracing context is // thread-local and does not cross `tokio::spawn` on its own). let turn_span = tracing::info_span!( "ai.persona_chat.turn.execute", turn_id = %turn_id, persona_id = %persona_id, conversation_id = %conversation_id, library_id = library_id, ); let handle = tokio::spawn( async move { let span = tracing::Span::current(); let result = svc .run_streaming_turn( persona_for_task, req_for_task, entry_clone.clone(), user_id, library_id, &span, ) .await; match result { Ok(()) => { span.set_attribute("status", "done"); span.set_status(Status::Ok); } Err(e) => { span.set_attribute("status", "error"); span.set_status(Status::error(format!("{e}"))); let _ = entry_clone .push_event(ChatStreamEvent::Error(format!("{e}"))) .await; } } entry_clone.set_terminal_status(crate::ai::turn_registry::TurnStatus::Done); // Frees the in-flight slot. Placed explicitly so the guard is // captured by the spawned task: it must drop when the task ends // (completion, error, or abort) — not when dispatch returns. drop(in_flight_guard); } .instrument(turn_span), ); entry.set_abort_handle(handle.abort_handle()); Ok(turn_id) } /// The streaming turn body. Loads the persona, builds the messages /// vector (system + persisted history + new user turn), runs the /// shared agent loop, persists the updated transcript. async fn run_streaming_turn( self: Arc, persona: crate::database::models::Persona, req: PersonaChatTurnRequest, entry: Arc, user_id: i32, _library_id: i32, turn_span: &tracing::Span, ) -> Result<()> { // Per-conversation mutex — serializes this turn against a // concurrent rewind or branch switch on the same transcript. let conversation_id = req.conversation_id.trim().to_string(); let lock = { let mut locks = self.chat_locks.lock().await; locks .entry(conversation_id.clone()) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() }; let _guard = lock.lock().await; // Replay the active branch of the conversation tree. let row = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); dao.get_persona_chat(user_id, &conversation_id) .map_err(|e| anyhow!("failed to load persona chat: {e:?}"))? }; let row = row.ok_or_else(|| anyhow!("conversation not found"))?; let needs_title = row.title.trim().is_empty(); let system_prompt = resolve_persona_system_prompt(Some(&persona)) .unwrap_or_else(|| "You are a helpful assistant.".to_string()); // An anchored chat carries the SMS thread in its system prompt, // re-composed each turn like the persona prompt itself. let system_prompt = match &row.anchor { Some(anchor) => crate::ai::sms_anchor::anchored_system_prompt( &system_prompt, anchor, &crate::ai::user_display_name(), self.generator.display_zone(), ), None => system_prompt, }; let stored = Some(decode_store(&row.messages_json)?); // `seeded` marks a conversation whose messages are NOT yet backed by // tree nodes, so the persistence step below knows to write the seed // out along with the turn. let (mut store, mut messages, seeded) = match stored { Some(store) if !store.nodes.is_empty() => { let mut messages: Vec = { let path = store.path_to_leaf(store.active_leaf_id).ok_or_else(|| { anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id) })?; path.iter().map(|n| n.message.clone()).collect() }; // Re-resolve the system prompt from the persona on every turn. // The stored node stays as the historical record of what the // model saw; editing a persona has to take effect on // conversations already in flight, which it would not if we // replayed the copy seeded at conversation start forever. if let Some(sys) = messages.first_mut() && sys.role == "system" { sys.content = system_prompt.clone(); } (store, messages, false) } _ => ( ChatHistoryStore { nodes: Vec::new(), active_leaf_id: 0, }, seed_messages_for_persona(&system_prompt), true, ), }; // Backend selection. Defaults to local; explicit `backend` wins. let backend_kind = match req .backend .as_deref() .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_lowercase) .as_deref() { Some("hybrid") => BackendKind::Hybrid, _ => BackendKind::Local, }; let max_iterations = DEFAULT_MAX_ITERATIONS.clamp(1, max_chat_iterations()); let overrides = SamplingOverrides { model: req.model.clone().filter(|m| !m.is_empty()), num_ctx: req.num_ctx, temperature: req.temperature, top_p: req.top_p, top_k: req.top_k, min_p: req.min_p, enable_thinking: req.enable_thinking, }; let backend = self .generator .resolve_backend(backend_kind, &overrides) .await?; let model_used = backend.model().to_string(); // llm.* attributes on the turn-level span (issue #130). The child // llm.request spans parent on it ambiently (the task is instrumented). turn_span.set_attribute("llm.backend", backend.kind.as_str()); turn_span.set_attribute("llm.model", model_used.clone()); // Build a no-photo tool catalog. `current_gate_opts_for_persona` // already probes each tool's backing table for presence and // honours the persona's `allow_agent_corrections` toggle. // `has_vision` only gates `describe_photo`, which describes the file // chat's photo. A persona chat has no photo, so the tool could only // ever answer "No image available" and waste a round. Vision in a // persona chat goes through the thread tools (look_at_message_image, // look_at_photo), which use the local vision model directly. let mut gate_opts = self .generator .current_gate_opts_for_persona(false, Some((user_id, &persona.persona_id))); gate_opts.sms_thread_tools = row.anchor.is_some(); let tools = InsightGenerator::build_tool_definitions(gate_opts); // Practice mode narrows the catalog to the thread itself — no // memory, library or calendar access for a simulated contact. let tools = match &row.anchor { Some(anchor) => crate::ai::sms_anchor::tools_for_mode(tools, anchor.mode), None => tools, }; // Append the new user turn + apply the per-turn system-prompt // override. Per-persona overrides are NOT persisted (the persona // voice survives a voice-flipped turn); applied ephemerally. let override_text = req.system_prompt.as_deref(); let prompt_override = if let Some(s) = override_text { let t = s.trim(); if t.is_empty() { None } else { Some(t.to_string()) } } else { None }; // Trim to the model's context window before the turn. Without this a // rolling persona transcript grows unbounded until the backend // silently drops the front of it mid-conversation. let mut fitter = ContextFitter::for_client(backend.chat(), req.num_ctx).await; let truncated = fitter.trim(&mut messages); if truncated { let _ = entry.push_event(ChatStreamEvent::Truncated).await; } // How much of `messages` is already backed by tree nodes. Read after // the budget pass, because truncation drains replayed history out of // the middle — taking the path length instead would misalign the // slice below and re-append old turns as new nodes. let in_tree_len = if seeded { 0 } else { messages.len() }; messages.push(ChatMessage::user(req.user_message.clone())); if let Some(ref note) = prompt_override { // Suffix the system prompt with the override for this turn only. // Restored to the persisted value below before serialization. if let Some(sys) = messages.iter_mut().find(|m| m.role == "system") { sys.content = format!("{}\n\n{}", sys.content, note); } } // Run the shared agent loop. `image_base64 = None` and `normalized = ""` // because there's no photo — `execute_tool` is gated by tool name and // returns "tool not found" for any file-specific tool the LLM might // hallucinate. let outcome = crate::ai::insight_chat::run_streaming_agentic_loop_with_entry( &self.generator, &backend, &mut messages, tools, &None, "", user_id, &persona.persona_id, // Thread tools only ever read this chat's own thread. row.anchor.as_ref().map(|a| a.contact_id), max_iterations, &entry, &mut fitter, ) .await .map_err(|e| anyhow!("persona chat loop failed: {e:?}"))?; // Cancelled mid-flight: the DELETE handler has already pushed the // terminal frame and flipped the registry status. Persisting here // would commit a half-finished turn and emit a second terminal frame. if outcome.cancelled { return Ok(()); } // Restore the persisted system prompt before serializing (the // override was for this turn only). if let Some(ref note) = prompt_override && let Some(sys) = messages.iter_mut().find(|m| m.role == "system") { let trimmed = format!("\n\n{}", note); if sys.content.ends_with(&trimmed) { sys.content.truncate(sys.content.len() - trimmed.len()); } } // Append this turn as a chain of nodes hanging off the branch we // replayed. Rewind and fork work by re-pointing `active_leaf_id` at // an earlier node, so a discarded path stays reachable instead of // being overwritten. let mut parent_id = if seeded { None } else { Some(store.active_leaf_id) }; for msg in &messages[in_tree_len..] { parent_id = Some(store.append_node(parent_id, msg.clone())); } if let Some(last_id) = parent_id { store.active_leaf_id = last_id; } let json = encode_store(&store)?; // Assistant turns on the new active branch. Cumulative, unlike the // per-write delta this column used to hold, so the list screen can // show how long a conversation actually is. let turn_count = store .path_to_leaf(store.active_leaf_id) .map(|path| render_persona_path(&store, &path).1) .unwrap_or(0) as i32; { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); let rows = dao .update_persona_chat( user_id, &conversation_id, &json, turn_count, Utc::now().timestamp_millis(), ) .map_err(|e| anyhow!("failed to persist persona chat: {e:?}"))?; if rows == 0 { bail!("conversation not found"); } } // Name the conversation off its opening exchange. Best-effort and // after persistence: a failed title must not lose the turn, and the // list falls back to the persona name while the title is empty. if needs_title { let title = self .generate_title(&backend, &messages) .await .unwrap_or_else(|| fallback_title(&messages)); if !title.is_empty() { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); if let Err(e) = dao.set_persona_chat_title(user_id, &conversation_id, &title) { log::warn!("failed to store persona chat title: {e:?}"); } } } let _ = entry .push_event(ChatStreamEvent::Done { tool_calls_made: outcome.tool_calls_made, iterations_used: outcome.iterations_used, truncated, prompt_tokens: outcome.last_prompt_eval_count, eval_tokens: outcome.last_eval_count, num_ctx: req.num_ctx, amended_insight_id: None, backend_used: backend_kind.as_str().to_string(), model_used, cancelled: outcome.cancelled, }) .await; Ok(()) } /// Every conversation this user has going, newest first. /// /// Conversations whose persona has since been deleted are omitted: they /// cannot be resumed (dispatching a turn against an unknown persona is a /// 404), so listing them would only offer a dead-end tap target. /// Conversations that hold nothing but the seed are omitted too — the /// user has never actually said anything in them. /// /// `contact_id` narrows the list to conversations anchored on that SMS /// thread — the Messages screen's per-thread persona list. pub fn list_conversations( &self, user_id: i32, contact_id: Option, ) -> Result> { let (rows, personas) = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); let rows = match contact_id { Some(cid) => dao.list_persona_chats_for_contact(user_id, cid), None => dao.list_persona_chats(user_id), } .map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?; let personas = dao .list_personas(user_id) .map_err(|e| anyhow!("failed to list personas: {e:?}"))?; (rows, personas) }; let names: HashMap = personas .into_iter() .map(|p| (p.persona_id, p.name)) .collect(); let mut out = Vec::with_capacity(rows.len()); for entry in rows { let persona_name = if is_practice(&entry) { PRACTICE_PERSONA_NAME.to_string() } else { match names.get(&entry.persona_id) { Some(name) => name.clone(), None => continue, } }; // A single unreadable transcript shouldn't take the whole list // down with it — skip the row and keep going. let Ok(store) = decode_store(&entry.messages_json) else { log::warn!( "skipping unreadable persona chat transcript {}", entry.conversation_id ); continue; }; // An empty conversation still gets a row: the user created it and // needs somewhere to tap back into. It just has no preview yet. let rendered = store .path_to_leaf(store.active_leaf_id) .map(|path| render_persona_path(&store, &path)); let (messages, turn_count) = match rendered { Some((messages, turn_count, _ids, _forks)) => (messages, turn_count), None => (Vec::new(), 0), }; let last = messages.last(); out.push(PersonaConversationSummary { conversation_id: entry.conversation_id, persona_id: entry.persona_id, persona_name, title: entry.title, snippet: last .map(|m| conversation_snippet(&m.content)) .unwrap_or_default(), snippet_role: last.map(|m| m.role.clone()).unwrap_or_default(), turn_count: turn_count as u32, message_count: messages.len(), created_at: entry.created_at, updated_at: entry.updated_at, has_branches: store.leaves().len() > 1, anchor: entry.anchor.as_ref().map(PersonaChatAnchorView::from), }); } Ok(out) } /// Ask the model to name a conversation from its opening exchange. /// /// Returns `None` on any failure — a title is a nicety, and the caller /// falls back to the user's first message. Uses the same backend the /// turn ran on so a local-only setup never reaches the network. async fn generate_title( &self, backend: &crate::ai::backend::ResolvedBackend, messages: &[ChatMessage], ) -> Option { let user_message = messages.iter().find(|m| m.role == "user")?; // The last assistant message with actual prose — earlier ones may be // empty tool-dispatch scaffolding. let reply = messages .iter() .rev() .find(|m| m.role == "assistant" && !m.content.trim().is_empty())?; let prompt = title_prompt(&user_message.content, &reply.content); match backend.chat().generate(&prompt, None, None).await { Ok(raw) => { let title = sanitize_title(&crate::ai::llm_client::strip_think_blocks(&raw)); if title.is_empty() { None } else { Some(title) } } Err(e) => { log::warn!("persona chat title generation failed: {e:?}"); None } } } /// Load the tree, mutate it under the per-conversation lock, and persist. /// /// Every branch operation is the same three steps around a different /// mutation, and all of them have to serialize against an in-flight turn /// (which appends to `active_leaf_id`) or the two writers clobber each /// other's copy of the transcript. async fn mutate_store(&self, user_id: i32, conversation_id: &str, mutate: F) -> Result<()> where F: FnOnce(&mut ChatHistoryStore) -> Result<()>, { let lock = { let mut locks = self.chat_locks.lock().await; locks .entry(conversation_id.to_string()) .or_insert_with(|| Arc::new(TokioMutex::new(()))) .clone() }; let _guard = lock.lock().await; let row = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); dao.get_persona_chat(user_id, conversation_id) .map_err(|e| anyhow!("failed to load persona chat: {e:?}"))? }; let row = row.ok_or_else(|| anyhow!("conversation not found"))?; let mut store = decode_store(&row.messages_json)?; if store.nodes.is_empty() { bail!("no chat history for this conversation"); } mutate(&mut store)?; let json = encode_store(&store)?; let turn_count = store .path_to_leaf(store.active_leaf_id) .map(|path| render_persona_path(&store, &path).1) .unwrap_or(0) as i32; let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); let rows = dao .update_persona_chat( user_id, conversation_id, &json, turn_count, Utc::now().timestamp_millis(), ) .map_err(|e| anyhow!("failed to persist persona chat: {e:?}"))?; if rows == 0 { bail!("conversation not found"); } Ok(()) } /// Rewind so the rendered message at `discard_from_rendered_index` — and /// everything after it — leaves the active path. /// /// Nothing is deleted: the active leaf is re-pointed at the last kept /// node, and the discarded path survives as a fork the user can switch /// back to. Unlike the file chat, index 0 is rewindable: its index 0 is a /// synthetic "describe this photo" prompt, whereas here it is the user's /// own first question and editing it is a reasonable thing to want. pub async fn rewind( &self, user_id: i32, conversation_id: &str, discard_from_rendered_index: usize, ) -> Result<()> { self.mutate_store(user_id, conversation_id, |store| { let path = store .path_to_leaf(store.active_leaf_id) .ok_or_else(|| anyhow!("active_leaf_id not found in tree"))?; let (_rendered, _turns, node_ids, _forks) = render_persona_path(store, &path); store.active_leaf_id = rewind_target_leaf(store, &node_ids, discard_from_rendered_index)?; Ok(()) }) .await } /// Make `leaf_id` the active branch. The path being left behind becomes /// a regular fork rather than being discarded. pub async fn switch_branch( &self, user_id: i32, conversation_id: &str, leaf_id: u64, ) -> Result<()> { self.mutate_store(user_id, conversation_id, |store| { if !store.nodes.iter().any(|n| n.id == leaf_id) { bail!("branch_id {leaf_id} not found in tree"); } if !store.children_of(leaf_id).is_empty() { bail!("branch_id {leaf_id} is not a leaf node"); } store.active_leaf_id = leaf_id; Ok(()) }) .await } /// List the branches of a persona's conversation tree. /// /// With `node_id` set (from a rendered message's `fork_info.node_id`) /// the list is the position-ranked siblings at that one divergence; /// without it, every leaf in the tree. pub fn get_branches( &self, user_id: i32, conversation_id: &str, node_id: Option, viewing_leaf: Option, ) -> Result<(Vec, u64)> { let row = { let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); dao.get_persona_chat(user_id, conversation_id) .map_err(|e| anyhow!("failed to load persona chat: {e:?}"))? }; let row = row.ok_or_else(|| anyhow!("conversation not found"))?; let store = decode_store(&row.messages_json)?; if store.nodes.is_empty() { bail!("no chat history for this conversation"); } let list = match node_id { Some(fork_node) => { if !store.nodes.iter().any(|n| n.id == fork_node) { bail!("node_id {fork_node} not found in tree"); } store.branch_options_at(fork_node, viewing_leaf.unwrap_or(store.active_leaf_id)) } None => store.leaves_with_info(), }; Ok((list, store.active_leaf_id)) } } // ───────────────────────────────────────────────────────────────────── // HTTP handlers — /persona_chat/* // ───────────────────────────────────────────────────────────────────── /// Query params for GET /persona_chat/history. `library` is accepted for /// client parity with the file chat but does not scope persistence — a /// persona transcript is keyed on `(user_id, persona_id)` only. #[derive(Debug, Deserialize)] pub struct PersonaChatHistoryQuery { pub conversation_id: String, #[serde(default)] #[allow(dead_code)] pub library: Option, /// Render the branch anchored at this leaf instead of the active one, /// so the client can preview a fork without committing to it. #[serde(default)] pub branch_id: Option, } /// Body for POST /persona_chat/conversations — start a new conversation. #[derive(Debug, Deserialize)] pub struct PersonaChatCreateRequest { /// Required except for a practice (`as_contact`) anchor, which plays /// the contact rather than a persona. #[serde(default)] pub persona_id: String, /// Anchor the conversation on a message in an SMS thread ("Continue /// with persona"). Omitted for an open chat. #[serde(default)] pub anchor: Option, } /// SMS-API ids of the thread and message to pick up from. #[derive(Debug, Deserialize)] pub struct PersonaChatAnchorRequest { pub contact_id: i64, pub message_id: i64, /// `about` (default) or `as_contact`. #[serde(default)] pub mode: crate::database::AnchorMode, } /// Query params for GET /persona_chat/conversations. #[derive(Debug, Deserialize)] pub struct PersonaChatListQuery { /// Only conversations anchored on this SMS thread. #[serde(default)] pub contact_id: Option, } /// GET /persona_chat/history?persona_id=... — the rendered rolling /// transcript. A persona that has never been chatted returns the empty /// envelope (200), so the client can render its empty state. #[get("/persona_chat/history")] pub async fn persona_chat_history_handler( _claims: Claims, query: web::Query, app_state: web::Data, ) -> impl Responder { let user_id = _claims.sub.parse::().unwrap_or(1); match app_state.persona_chat_session.load_history( user_id, &query.conversation_id, query.branch_id, ) { Ok(view) => HttpResponse::Ok().json(view), Err(e) => { if format!("{e}").contains("not found") { ApiError::not_found( "conversation_not_found", "That conversation no longer exists", ) .into_response() } else { ApiError::internal(e).into_response() } } } } /// POST /persona_chat/turn — dispatch an async turn. Returns 202 with /// the `turn_id` immediately; the streaming agent loop runs in a /// background task and the client opens the SSE replay stream with it. #[post("/persona_chat/turn")] #[tracing::instrument(name = "http.persona_chat.turn", skip_all)] pub async fn persona_chat_turn_handler( claims: Claims, request: web::Json, app_state: web::Data, ) -> impl Responder { let span = tracing::Span::current(); span.set_attribute("conversation_id", request.conversation_id.clone()); // The transcript is not library-scoped, but the turn's tool catalog // (memories, SMS, places) resolves against a library — mirror the // file chat's resolution so a `?library=` pick steers the tools. let library = match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) { Ok(Some(lib)) => lib, Ok(None) => app_state.primary_library(), Err(e) => return crate::libraries::unknown_library(e).into_response(), }; let user_id = claims.sub.parse::().unwrap_or(1); let session = app_state.persona_chat_session.clone(); let registry = app_state.turn_registry.clone(); match session .dispatch_turn(registry, user_id, library.id, request.into_inner()) .await { Ok(turn_id) => { span.set_attribute("turn_id", turn_id.clone()); span.set_status(Status::Ok); HttpResponse::Accepted().json(serde_json::json!({ "turn_id": turn_id, "status": "running" })) } Err(e) => { span.set_status(Status::error(format!("{e}"))); ApiError::from(&e).into_response() } } } /// POST /persona_chat/conversations — start a new conversation with a /// persona. Always creates a fresh transcript, so a persona can hold several /// separate threads. #[post("/persona_chat/conversations")] pub async fn persona_chat_create_conversation_handler( claims: Claims, request: web::Json, app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); let session = &app_state.persona_chat_session; let created = match &request.anchor { Some(anchor) => { session .create_anchored_conversation( user_id, &request.persona_id, anchor.contact_id, anchor.message_id, anchor.mode, ) .await } None => session.create_conversation(user_id, &request.persona_id), }; match created { Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({ "conversation_id": conversation_id, })), Err(e) => ApiError::from(&e).into_response(), } } /// DELETE /persona_chat/conversations/{conversation_id} — remove a /// conversation and its transcript. #[delete("/persona_chat/conversations/{conversation_id}")] pub async fn persona_chat_delete_conversation_handler( claims: Claims, path: web::Path, app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); match app_state .persona_chat_session .delete_conversation(user_id, &path.into_inner()) { Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "deleted": true })), Err(e) => { log::error!("persona chat delete failed: {e}"); ApiError::internal(format!("{e}")).into_response() } } } /// GET /persona_chat/conversations — every conversation this user has /// going, newest first. Backs the chat list screen; the detail screen loads /// a transcript from `/persona_chat/history`. #[get("/persona_chat/conversations")] pub async fn persona_chat_conversations_handler( claims: Claims, query: web::Query, app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); match app_state .persona_chat_session .list_conversations(user_id, query.contact_id) { Ok(conversations) => HttpResponse::Ok().json(serde_json::json!({ "conversations": conversations, })), Err(e) => { log::error!("persona chat conversation list failed: {e}"); ApiError::internal(format!("{e}")).into_response() } } } /// Body for POST /persona_chat/rewind. #[derive(Debug, Deserialize)] pub struct PersonaChatRewindRequest { pub conversation_id: String, /// 0-based index into the rendered transcript. This message and /// everything after it leaves the active path (and stays reachable as a /// fork). Unlike the file chat, 0 is a legal index here. pub discard_from_rendered_index: usize, } /// Body for POST /persona_chat/switch-branch. #[derive(Debug, Deserialize)] pub struct PersonaChatSwitchBranchRequest { pub conversation_id: String, /// Leaf node to make active. Must be an existing leaf in the tree. pub branch_id: u64, } /// Query params for GET /persona_chat/branches. #[derive(Debug, Deserialize)] pub struct PersonaChatBranchesQuery { pub conversation_id: String, /// From a rendered message's `fork_info.node_id` — scopes the list to /// the siblings at that one divergence instead of every leaf. #[serde(default)] pub node_id: Option, /// The leaf the client is showing, so scoped options in the same subtree /// anchor to it and the client can spot "the branch I am on". #[serde(default)] pub viewing_branch_id: Option, } /// Map a branch-operation error onto the same status codes the file chat's /// equivalents return, so the client can share its error handling. fn branch_error_response(context: &str, e: &anyhow::Error) -> HttpResponse { let msg = format!("{e}"); log::warn!("{context}: {msg}"); if msg.contains("conversation not found") { ApiError::not_found( "conversation_not_found", "That conversation no longer exists", ) } else if msg.contains("no chat history") { ApiError::conflict("no_chat_history", "There's no chat here yet") } else if msg.contains("not found") || msg.contains("not a leaf") || msg.contains("out of range") || msg.contains("cannot rewind past") { // The client's view of the branches is stale. ApiError::bad_request( "stale_branch", "This conversation changed. Reload it and try again.", ) .with_details(serde_json::json!({ "reason": msg })) } else { ApiError::internal(msg) } .into_response() } /// POST /persona_chat/rewind — move the tail of the conversation off the /// active path. Nothing is deleted; the discarded path stays reachable as a /// fork, which is what makes "edit & resend" non-destructive. #[post("/persona_chat/rewind")] pub async fn persona_chat_rewind_handler( claims: Claims, request: web::Json, app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); match app_state .persona_chat_session .rewind( user_id, &request.conversation_id, request.discard_from_rendered_index, ) .await { Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "success": true })), Err(e) => branch_error_response("persona chat rewind failed", &e), } } /// POST /persona_chat/switch-branch — make an alternate path the active one. #[post("/persona_chat/switch-branch")] pub async fn persona_chat_switch_branch_handler( claims: Claims, request: web::Json, app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); match app_state .persona_chat_session .switch_branch(user_id, &request.conversation_id, request.branch_id) .await { Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "success": true })), Err(e) => branch_error_response("persona chat branch switch failed", &e), } } /// GET /persona_chat/branches — list the alternate paths of a persona's /// conversation. Pair an entry's `id` with the history endpoint's /// `branch_id` to preview it, or with switch-branch to adopt it. #[get("/persona_chat/branches")] pub async fn persona_chat_branches_handler( claims: Claims, query: web::Query, app_state: web::Data, ) -> impl Responder { let user_id = claims.sub.parse::().unwrap_or(1); match app_state.persona_chat_session.get_branches( user_id, &query.conversation_id, query.node_id, query.viewing_branch_id, ) { Ok((branches, active_leaf_id)) => HttpResponse::Ok().json(serde_json::json!({ "branches": branches, "active_leaf_id": active_leaf_id, })), Err(e) => branch_error_response("persona chat branch list failed", &e), } } /// GET /persona_chat/turn/{turn_id} — SSE replay for a persona turn. /// Delegates to the file chat's replay handler: the registry is keyed /// on `turn_id` only, and the `turn_info` frame already carries /// `scope: "persona"` + `persona_id` (and no `file_path`) via the /// shared renderer. #[get("/persona_chat/turn/{turn_id}")] pub async fn persona_turn_replay_handler( path: web::Path, query: web::Query, app_state: web::Data, ) -> HttpResponse { crate::ai::handlers::turn_replay_impl(path, query, app_state).await } /// DELETE /persona_chat/turn/{turn_id} — cancel a running persona turn. #[delete("/persona_chat/turn/{turn_id}")] pub async fn persona_turn_cancel_handler( path: web::Path, app_state: web::Data, ) -> impl Responder { crate::ai::handlers::cancel_turn_impl(path, app_state).await } // ───────────────────────────────────────────────────────────────────── // Unit tests — pure helpers only. DAO + handler tests live in their // respective test modules (`persona_chat_dao.rs` and `handlers.rs`). // ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; fn persona_with_prompt(p: &str) -> crate::database::models::Persona { crate::database::models::Persona { id: 0, user_id: 1, persona_id: "test".to_string(), name: "Tester".to_string(), system_prompt: p.to_string(), is_built_in: false, include_all_memories: false, created_at: 0, updated_at: 0, reviewed_only_facts: false, allow_agent_corrections: false, } } #[test] fn resolve_persona_system_prompt_returns_stored_prompt() { let p = persona_with_prompt("You are X."); assert_eq!( resolve_persona_system_prompt(Some(&p)).as_deref(), Some("You are X.") ); } #[test] fn resolve_persona_system_prompt_falls_back_to_none_when_empty() { let p = persona_with_prompt(""); assert!(resolve_persona_system_prompt(Some(&p)).is_none()); } #[test] fn resolve_persona_system_prompt_handles_missing_persona() { assert!(resolve_persona_system_prompt(None).is_none()); } fn assistant_msg(content: &str) -> ChatMessage { ChatMessage { role: "assistant".to_string(), content: content.to_string(), tool_calls: None, images: None, } } #[test] fn seed_messages_for_persona_is_system_only() { // No synthetic first user turn (there is no photo to anchor on) and // no greeting (v1 shipped one, but it never rendered). let msgs = seed_messages_for_persona("Be terse."); assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].role, "system"); assert_eq!(msgs[0].content, "Be terse."); } fn greeting(name: &str) -> ChatMessage { assistant_msg(&format!( "Hi — I'm {name} ready to help. Ask anything your tools can reach \ (memories, files, SMS, calendar, places)." )) } #[test] fn decode_store_upgrades_a_flat_transcript_into_a_linear_tree() { let flat = vec![ ChatMessage::system("sys".to_string()), ChatMessage::user("hi".to_string()), assistant_msg("hello"), ]; let store = decode_store(&serde_json::to_string(&flat).unwrap()).unwrap(); assert_eq!(store.nodes.len(), 3); assert_eq!(store.nodes[0].parent_id, None); assert_eq!(store.nodes[1].parent_id, Some(store.nodes[0].id)); assert_eq!(store.nodes[2].parent_id, Some(store.nodes[1].id)); assert_eq!(store.active_leaf_id, store.nodes[2].id); } #[test] fn decode_store_drops_the_v1_seed_greeting_on_upgrade() { // Pre-migration rows carry an assistant greeting the flat renderer // hid. The tree renderer renders every assistant node, so the // upgrade has to drop it or the user gains a bubble they never saw. let flat = vec![ ChatMessage::system("sys".to_string()), greeting("Journal"), ChatMessage::user("hi".to_string()), ]; let store = decode_store(&serde_json::to_string(&flat).unwrap()).unwrap(); assert_eq!(store.nodes.len(), 2); assert!( !store.nodes.iter().any(|n| n.message.role == "assistant"), "greeting node survived the upgrade" ); } #[test] fn decode_store_keeps_a_real_reply_that_reads_like_the_greeting() { // Same words, but after a user turn — a genuine reply, not the seed. let flat = vec![ ChatMessage::system("sys".to_string()), ChatMessage::user("who are you?".to_string()), greeting("Journal"), ]; let store = decode_store(&serde_json::to_string(&flat).unwrap()).unwrap(); assert_eq!(store.nodes.len(), 3); assert_eq!(store.nodes[2].message.role, "assistant"); } #[test] fn encode_decode_store_round_trips_a_forked_tree() { let mut store = ChatHistoryStore::from_flat_array(vec![ ChatMessage::system("sys".to_string()), ChatMessage::user("q".to_string()), ]); let user_id = store.active_leaf_id; let a1 = store.append_node(Some(user_id), assistant_msg("first answer")); let a2 = store.append_node(Some(user_id), assistant_msg("second answer")); store.active_leaf_id = a2; let back = decode_store(&encode_store(&store).unwrap()).unwrap(); assert_eq!(back.active_leaf_id, a2); assert_eq!(back.nodes.len(), 4); assert_eq!(back.children_of(user_id).len(), 2); assert_eq!(back.fork_at_node(a1), Some((1, 2))); assert_eq!(back.fork_at_node(a2), Some((2, 2))); } #[test] fn decode_store_rejects_garbage() { let err = decode_store("not-json").unwrap_err(); assert!(err.to_string().contains("failed to deserialize")); } #[test] fn render_persona_path_never_marks_a_message_initial() { // The file chat reserves is_initial for its synthetic "describe this // photo" prompt, which the UI refuses to rewind or regenerate. A // persona chat's first message is the user's own question, so both // actions have to stay available on it. let store = ChatHistoryStore::from_flat_array(vec![ ChatMessage::system("sys".to_string()), ChatMessage::user("first question".to_string()), assistant_msg("reply"), ]); let path = store.path_to_leaf(store.active_leaf_id).unwrap(); let (rendered, turn_count, node_ids, fork_info) = render_persona_path(&store, &path); assert_eq!(rendered.len(), 2, "the system node is not rendered"); assert!(rendered.iter().all(|m| !m.is_initial)); assert_eq!(rendered[0].role, "user"); assert_eq!(rendered[1].role, "assistant"); assert_eq!(turn_count, 1, "one assistant turn on this path"); assert_eq!(node_ids.len(), 2); assert!(fork_info.iter().all(|f| f.is_none()), "no divergence yet"); } #[test] fn render_persona_path_marks_the_divergent_reply_on_a_forked_tree() { let mut store = ChatHistoryStore::from_flat_array(vec![ ChatMessage::system("sys".to_string()), ChatMessage::user("q".to_string()), ]); let user_node = store.active_leaf_id; let _a1 = store.append_node(Some(user_node), assistant_msg("first answer")); let a2 = store.append_node(Some(user_node), assistant_msg("second answer")); store.active_leaf_id = a2; let path = store.path_to_leaf(a2).unwrap(); let (rendered, _turns, _ids, fork_info) = render_persona_path(&store, &path); assert_eq!(rendered.len(), 2); assert!( fork_info[0].is_none(), "the user turn itself has not forked" ); let f = fork_info[1] .as_ref() .expect("divergence on the second reply"); assert_eq!((f.position, f.total), (2, 2)); assert_eq!( f.node_id, user_node, "divergence is attributed to the user turn" ); } #[test] fn wire_fork_info_preserves_position_total_and_node() { let wired = wire_fork_info(vec![ None, Some(ForkInfo { position: 2, total: 3, node_id: 7, }), ]); assert!(wired[0].is_none()); let f = wired[1].as_ref().unwrap(); assert_eq!((f.position, f.total, f.node_id), (2, 3, 7)); } #[test] fn persona_chat_history_view_empty_has_zero_counts() { let v = PersonaChatHistoryView::empty(); assert_eq!(v.messages.len(), 0); assert_eq!(v.turn_count, 0); assert_eq!(v.active_leaf_id, 0); assert_eq!(v.viewing_branch_id, 0); assert!(v.fork_info.is_empty()); } #[test] fn persona_chat_validation_flags_empty_user_message() { let _req = PersonaChatTurnRequest { conversation_id: "conv-1".to_string(), user_message: " ".to_string(), model: None, backend: None, num_ctx: None, temperature: None, top_p: None, top_k: None, min_p: None, enable_thinking: None, system_prompt: None, library: None, }; assert_eq!( format!("{}", PersonaChatError::EmptyMessage), "user_message must not be empty" ); } #[test] fn persona_chat_validation_flags_oversized_user_message() { let _req = PersonaChatTurnRequest { conversation_id: "conv-1".to_string(), user_message: "x".repeat(8193), model: None, backend: None, num_ctx: None, temperature: None, top_p: None, top_k: None, min_p: None, enable_thinking: None, system_prompt: None, library: None, }; assert_eq!( format!("{}", PersonaChatError::MessageTooLong), "user_message exceeds 8192 chars" ); } #[test] fn persona_chat_validation_flags_unknown_persona() { let err = PersonaChatError::UnknownPersona("nope".to_string()); assert_eq!(format!("{err}"), "persona 'nope' not found"); } #[test] fn persona_chat_error_concurrent_turn_displays_message() { assert_eq!( format!("{}", PersonaChatError::ConcurrentTurn), "another turn for this persona is in flight" ); } #[test] fn persona_chat_turn_returns_409_when_concurrent_turn_in_flight() { let in_flight = InFlightPersonaTurns::new(); assert!(in_flight.claim("conv-a", "t1").is_ok()); // A second dispatch against the same conversation is rejected with // the in-flight turn's id (the handler maps this to HTTP 409), // and the rejection does not clobber the in-flight claim. assert_eq!(in_flight.claim("conv-a", "t2").unwrap_err(), "t1"); assert_eq!(in_flight.claim("conv-a", "t3").unwrap_err(), "t1"); // A second conversation with the same persona runs independently — // that is the whole point of separate conversations. assert!(in_flight.claim("conv-b", "t4").is_ok()); // Releasing frees the slot for a fresh dispatch. in_flight.release("conv-a"); assert!(in_flight.claim("conv-a", "t6").is_ok()); } #[test] fn in_flight_guard_releases_slot_on_drop() { let in_flight = Arc::new(InFlightPersonaTurns::new()); let tracker = in_flight.clone(); // Mirrors dispatch_turn: claim first, guard holds the slot. assert!(tracker.claim("conv-a", "t-guard").is_ok()); { let _guard = InFlightGuard { in_flight, conversation_id: "conv-a".to_string(), }; assert_eq!(tracker.claim("conv-a", "other").unwrap_err(), "t-guard"); } // Guard dropped → slot free again. assert!(tracker.claim("conv-a", "t7").is_ok()); } #[test] fn practice_create_request_needs_no_persona() { let req: PersonaChatCreateRequest = serde_json::from_str( r#"{"anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#, ) .unwrap(); assert_eq!(req.persona_id, ""); } #[test] fn practice_persona_is_inert() { let p = practice_persona(7); assert_eq!(p.persona_id, PRACTICE_PERSONA_ID); assert_eq!(p.user_id, 7); assert!(p.system_prompt.is_empty()); assert!(!p.allow_agent_corrections); } #[test] fn create_request_anchor_mode_defaults_to_about() { use crate::database::AnchorMode; let req: PersonaChatCreateRequest = serde_json::from_str( r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812}}"#, ) .unwrap(); assert_eq!(req.anchor.unwrap().mode, AnchorMode::About); let req: PersonaChatCreateRequest = serde_json::from_str( r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#, ) .unwrap(); assert_eq!(req.anchor.unwrap().mode, AnchorMode::AsContact); let req: PersonaChatCreateRequest = serde_json::from_str(r#"{"persona_id":"journal"}"#).unwrap(); assert!(req.anchor.is_none()); } #[test] fn persona_chat_turn_request_deserializes_snake_case_wire_body() { // Pins the mobile-client contract: snake_case keys, same as the // file-chat request shapes. A camelCase regression here 400s the // client's dispatch with "missing field persona_id". let body = r#"{ "conversation_id": "conv-1", "user_message": "hello", "num_ctx": 4096, "top_p": 0.9, "top_k": 40, "min_p": 0.05, "enable_thinking": true, "system_prompt": "be brief", "library": "main" }"#; let req: PersonaChatTurnRequest = serde_json::from_str(body).unwrap(); assert_eq!(req.conversation_id, "conv-1"); assert_eq!(req.user_message, "hello"); assert_eq!(req.num_ctx, Some(4096)); assert_eq!(req.top_p, Some(0.9)); assert_eq!(req.top_k, Some(40)); assert_eq!(req.min_p, Some(0.05)); assert_eq!(req.enable_thinking, Some(true)); assert_eq!(req.system_prompt.as_deref(), Some("be brief")); assert_eq!(req.library.as_deref(), Some("main")); } #[test] fn persona_chat_history_view_serializes_snake_case_wire_shape() { let view = PersonaChatHistoryView { conversation_id: "conv-1".to_string(), persona_id: "journal".to_string(), title: "June recap".to_string(), messages: vec![RenderedPersonaMessage { role: "user".to_string(), content: "hi".to_string(), is_initial: true, tools: Vec::new(), }], turn_count: 1, model_version: "x".to_string(), backend: "local".to_string(), active_leaf_id: 4, viewing_branch_id: 2, fork_info: vec![Some(crate::ai::handlers::ChatForkInfo { position: 1, total: 2, node_id: 3, })], anchor: None, }; let json = serde_json::to_value(&view).unwrap(); // The client's shared ChatHistoryView reads snake_case keys. assert_eq!(json["conversation_id"], "conv-1"); assert_eq!(json["persona_id"], "journal"); assert_eq!(json["title"], "June recap"); assert_eq!(json["turn_count"], 1); assert_eq!(json["active_leaf_id"], 4); assert_eq!(json["viewing_branch_id"], 2); assert_eq!(json["messages"][0]["is_initial"], true); assert!(json["messages"][0].get("tools").is_none()); // fork_info matches the file chat's shape so the client's shared // branch-picker code can read either surface. assert_eq!(json["fork_info"][0]["position"], 1); assert_eq!(json["fork_info"][0]["total"], 2); assert_eq!(json["fork_info"][0]["node_id"], 3); } #[test] fn persona_chat_create_request_deserializes_snake_case_wire_body() { let req: PersonaChatCreateRequest = serde_json::from_str(r#"{"persona_id": "journal"}"#).unwrap(); assert_eq!(req.persona_id, "journal"); } /// system → user → assistant, plus a second assistant forking off the /// user turn. Returns (store, rendered node ids on the active path). fn forked_store() -> (ChatHistoryStore, Vec) { let mut store = ChatHistoryStore::from_flat_array(vec![ ChatMessage::system("sys".to_string()), ChatMessage::user("q".to_string()), ]); let user_node = store.active_leaf_id; let _a1 = store.append_node(Some(user_node), assistant_msg("first answer")); let a2 = store.append_node(Some(user_node), assistant_msg("second answer")); store.active_leaf_id = a2; let path = store.path_to_leaf(a2).unwrap(); let (_r, _t, node_ids, _f) = render_persona_path(&store, &path); (store, node_ids) } #[test] fn rewind_target_leaf_keeps_everything_before_the_discarded_index() { let (store, node_ids) = forked_store(); // Discard the reply (index 1) — the user turn at index 0 stays, and // the reply survives as a fork rather than being deleted. let leaf = rewind_target_leaf(&store, &node_ids, 1).unwrap(); assert_eq!(leaf, node_ids[0]); } #[test] fn rewind_target_leaf_at_zero_reanchors_on_the_seed_node() { // The file chat refuses index 0 because its index 0 is a synthetic // prompt. Here it is the user's own question, so editing it has to // work — the anchor becomes the system node above it. let (store, node_ids) = forked_store(); let leaf = rewind_target_leaf(&store, &node_ids, 0).unwrap(); assert_eq!(leaf, store.nodes[0].id, "anchors on the seed system node"); assert_eq!(store.nodes[0].message.role, "system"); } #[test] fn rewind_target_leaf_rejects_an_index_past_the_transcript() { let (store, node_ids) = forked_store(); let err = rewind_target_leaf(&store, &node_ids, 99).unwrap_err(); assert!(err.to_string().contains("out of range")); } #[test] fn rewind_target_leaf_errors_when_nothing_sits_above_index_zero() { // A transcript with no seed system node has nothing to re-anchor on. let store = ChatHistoryStore::from_flat_array(vec![ChatMessage::user("q".to_string())]); let path = store.path_to_leaf(store.active_leaf_id).unwrap(); let (_r, _t, node_ids, _f) = render_persona_path(&store, &path); let err = rewind_target_leaf(&store, &node_ids, 0).unwrap_err(); assert!(err.to_string().contains("cannot rewind past the start")); } #[test] fn rewind_target_leaf_rejects_an_empty_transcript() { let store = ChatHistoryStore::from_flat_array(Vec::new()); let err = rewind_target_leaf(&store, &[], 0).unwrap_err(); assert!(err.to_string().contains("out of range")); } #[test] fn sanitize_title_strips_the_quotes_small_models_add() { assert_eq!(sanitize_title("\"June recap\""), "June recap"); assert_eq!(sanitize_title("'June recap'"), "June recap"); assert_eq!(sanitize_title("“June recap”"), "June recap"); assert_eq!(sanitize_title("**June recap**"), "June recap"); } #[test] fn sanitize_title_strips_a_label_prefix_and_trailing_period() { assert_eq!(sanitize_title("Title: June recap."), "June recap"); assert_eq!(sanitize_title("Chat title: June recap"), "June recap"); assert_eq!( sanitize_title("CONVERSATION TITLE: June recap"), "June recap" ); } #[test] fn sanitize_title_takes_the_first_line_of_a_chatty_reply() { // Small models like to explain themselves after answering. assert_eq!( sanitize_title("June recap\n\nI chose this because it summarizes…"), "June recap" ); } #[test] fn sanitize_title_collapses_whitespace() { assert_eq!(sanitize_title(" June recap \t "), "June recap"); } #[test] fn sanitize_title_truncates_on_a_character_boundary() { // Byte-index slicing here would panic on the first accented word. let long = "é".repeat(TITLE_MAX_CHARS + 20); let title = sanitize_title(&long); assert_eq!(title.chars().count(), TITLE_MAX_CHARS + 1); assert!(title.ends_with('…')); } #[test] fn sanitize_title_returns_empty_for_junk() { assert_eq!(sanitize_title(" "), ""); assert_eq!(sanitize_title("\"\""), ""); } #[test] fn fallback_title_names_a_conversation_after_the_opening_question() { // Better than "Untitled": it is the thing the user scans for. let messages = vec![ ChatMessage::system("sys".to_string()), ChatMessage::user("What happened in June?".to_string()), assistant_msg("Quite a lot."), ]; assert_eq!(fallback_title(&messages), "What happened in June?"); } #[test] fn fallback_title_is_empty_without_a_user_message() { let messages = vec![ChatMessage::system("sys".to_string())]; assert_eq!(fallback_title(&messages), ""); } #[test] fn title_prompt_carries_both_sides_of_the_opening_exchange() { let prompt = title_prompt("What happened in June?", "Quite a lot."); assert!(prompt.contains("What happened in June?")); assert!(prompt.contains("Quite a lot.")); // The instruction the sanitizer is the backstop for. assert!(prompt.contains("at most six")); } #[test] fn conversation_snippet_flattens_whitespace_to_one_line() { // A markdown reply must not blow up the row height on the list. let snippet = conversation_snippet("first line\n\n- bullet\n- another"); assert_eq!(snippet, "first line - bullet - another"); } #[test] fn conversation_snippet_leaves_a_short_message_untouched() { assert_eq!(conversation_snippet("hello there"), "hello there"); } #[test] fn conversation_snippet_truncates_on_a_character_boundary() { // Multi-byte characters: a byte-index slice here would panic. let content = "é".repeat(SNIPPET_MAX_CHARS + 50); let snippet = conversation_snippet(&content); assert_eq!(snippet.chars().count(), SNIPPET_MAX_CHARS + 1); assert!(snippet.ends_with('…')); } #[test] fn conversation_snippet_handles_an_empty_message() { assert_eq!(conversation_snippet(" "), ""); } #[test] fn persona_conversation_summary_serializes_snake_case_wire_shape() { let summary = PersonaConversationSummary { conversation_id: "conv-1".to_string(), persona_id: "journal".to_string(), persona_name: "Journal".to_string(), title: "June recap".to_string(), snippet: "last thing said".to_string(), snippet_role: "assistant".to_string(), turn_count: 3, message_count: 6, created_at: 1_600_000_000_000, updated_at: 1_700_000_000_000, has_branches: true, anchor: Some(PersonaChatAnchorView { contact_id: 42, contact_name: "Sam".to_string(), message_id: 812, timestamp: 1_700_000_000, preview: "Sam: see you at 6".to_string(), mode: crate::database::AnchorMode::AsContact, }), }; let json = serde_json::to_value(&summary).unwrap(); assert_eq!(json["anchor"]["contact_id"], 42); assert_eq!(json["anchor"]["message_id"], 812); assert_eq!(json["anchor"]["preview"], "Sam: see you at 6"); assert_eq!(json["anchor"]["mode"], "as_contact"); assert_eq!(json["conversation_id"], "conv-1"); assert_eq!(json["title"], "June recap"); assert_eq!(json["persona_id"], "journal"); assert_eq!(json["persona_name"], "Journal"); assert_eq!(json["snippet_role"], "assistant"); assert_eq!(json["turn_count"], 3); assert_eq!(json["message_count"], 6); assert_eq!(json["updated_at"], 1_700_000_000_000i64); assert_eq!(json["has_branches"], true); } #[test] fn persona_chat_rewind_request_deserializes_snake_case_wire_body() { let req: PersonaChatRewindRequest = serde_json::from_str( r#"{"conversation_id": "conv-1", "discard_from_rendered_index": 3}"#, ) .unwrap(); assert_eq!(req.conversation_id, "conv-1"); assert_eq!(req.discard_from_rendered_index, 3); } #[test] fn persona_chat_switch_branch_request_deserializes_snake_case_wire_body() { let req: PersonaChatSwitchBranchRequest = serde_json::from_str(r#"{"conversation_id": "conv-1", "branch_id": 9}"#).unwrap(); assert_eq!(req.conversation_id, "conv-1"); assert_eq!(req.branch_id, 9); } #[test] fn persona_chat_branches_query_defaults_the_optional_scoping_params() { let q: PersonaChatBranchesQuery = serde_json::from_str(r#"{"conversation_id": "conv-1"}"#).unwrap(); assert_eq!(q.node_id, None, "unscoped listing walks every leaf"); assert_eq!(q.viewing_branch_id, None); let q: PersonaChatBranchesQuery = serde_json::from_str( r#"{"conversation_id": "conv-1", "node_id": 4, "viewing_branch_id": 6}"#, ) .unwrap(); assert_eq!(q.node_id, Some(4)); assert_eq!(q.viewing_branch_id, Some(6)); } #[test] fn branch_options_at_ranks_the_siblings_of_a_divergence() { // The list the branch picker renders: both replies to the same user // turn, position-ranked, with the viewed one anchored. let (store, _node_ids) = forked_store(); let user_node = store.nodes[1].id; let options = store.branch_options_at(user_node, store.active_leaf_id); assert_eq!(options.len(), 2); assert_eq!(options[0].position, Some(1)); assert_eq!(options[1].position, Some(2)); assert!( options.iter().any(|o| o.id == store.active_leaf_id), "the branch being viewed is one of the options" ); } #[test] fn persona_chat_history_query_accepts_an_optional_branch_id() { let q: PersonaChatHistoryQuery = serde_json::from_str(r#"{"conversation_id": "conv-1"}"#).unwrap(); assert_eq!( q.branch_id, None, "omitting branch_id renders the active branch" ); let q: PersonaChatHistoryQuery = serde_json::from_str(r#"{"conversation_id": "conv-1", "branch_id": 12}"#).unwrap(); assert_eq!(q.branch_id, Some(12)); } }