Files
ImageApi/src/ai/persona_chat.rs
T
Cameron CordesandClaude Opus 5.5 431c3a7492 Every error response in the { error, code, details? } format (step 2)
Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.

- 500s: the body was the error's text (anyhow / diesel / {:?}); now
  ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
  statuses are unchanged. Shared helpers per module (faces_unavailable,
  person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
  duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.

Closes #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:29:13 -05:00

2473 lines
97 KiB
Rust

//! 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<anyhow::Error> 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<String> {
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<ChatMessage> {
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<ChatMessage>` — rows written before the tree migration,
/// upgraded on read into a single linear branch.
pub fn decode_store(raw: &str) -> Result<ChatHistoryStore> {
if let Ok(flat) = serde_json::from_str::<Vec<ChatMessage>>(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<String> {
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<ChatMessage>) -> Vec<ChatMessage> {
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<String>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub num_ctx: Option<i32>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub top_k: Option<i32>,
#[serde(default)]
pub min_p: Option<f32>,
#[serde(default)]
pub enable_thinking: Option<bool>,
/// 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<String>,
#[serde(default)]
pub library: Option<String>,
}
#[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<RenderedPersonaMessage>,
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<Option<crate::ai::handlers::ChatForkInfo>>,
/// Where in an SMS thread this conversation picked up. Absent for an
/// open chat.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor: Option<PersonaChatAnchorView>,
}
/// 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<crate::ai::handlers::HistoryToolInvocation>,
}
/// 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<RenderedPersonaMessage>,
usize,
Vec<u64>,
Vec<Option<ForkInfo>>,
) {
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<u64> {
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::<Vec<_>>().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::<Vec<_>>().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<Option<ForkInfo>>,
) -> Vec<Option<crate::ai::handlers::ChatForkInfo>> {
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<PersonaChatAnchorView>,
}
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<HashMap<String, Arc<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<HashMap<String, String>>,
}
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<InFlightPersonaTurns>,
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<InsightGenerator>,
persona_dao: Arc<StdMutex<Box<dyn PersonaDao>>>,
chat_locks: Arc<PersonaLockMap>,
in_flight: Arc<InFlightPersonaTurns>,
}
impl PersonaChatSession {
pub fn new(
generator: Arc<InsightGenerator>,
persona_dao: Arc<StdMutex<Box<dyn PersonaDao>>>,
chat_locks: Arc<PersonaLockMap>,
) -> 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<String, PersonaChatError> {
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<String, PersonaChatError> {
// 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<u64>,
) -> Result<PersonaChatHistoryView> {
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<Self>,
registry: Arc<TurnRegistry>,
user_id: i32,
library_id: i32,
req: PersonaChatTurnRequest,
) -> Result<String, PersonaChatError> {
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<Self>,
persona: crate::database::models::Persona,
req: PersonaChatTurnRequest,
entry: Arc<TurnEntry>,
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<ChatMessage> = {
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<i64>,
) -> Result<Vec<PersonaConversationSummary>> {
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<String, String> = 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<String> {
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<F>(&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<u64>,
viewing_leaf: Option<u64>,
) -> Result<(Vec<crate::ai::llm_client::BranchLeafInfo>, 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<String>,
/// 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<u64>,
}
/// 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<PersonaChatAnchorRequest>,
}
/// 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<i64>,
}
/// 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<PersonaChatHistoryQuery>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = _claims.sub.parse::<i32>().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<PersonaChatTurnRequest>,
app_state: web::Data<AppState>,
) -> 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::<i32>().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<PersonaChatCreateRequest>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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<String>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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<PersonaChatListQuery>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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<u64>,
/// 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<u64>,
}
/// 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<PersonaChatRewindRequest>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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<PersonaChatSwitchBranchRequest>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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<PersonaChatBranchesQuery>,
app_state: web::Data<AppState>,
) -> impl Responder {
let user_id = claims.sub.parse::<i32>().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<String>,
query: web::Query<ReplayQuery>,
app_state: web::Data<AppState>,
) -> 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<String>,
app_state: web::Data<AppState>,
) -> 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<u64>) {
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));
}
}