From 3b9d9850255e83410619607d520d1d71b3af1639 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Mon, 24 Aug 2026 21:25:48 -0400 Subject: [PATCH] feat: add /persona_chat endpoints for open chat with persona - PersonaChatSession dispatches turns through the shared agent loop, persisting transcripts keyed on (user_id, persona_id) in the new persona_chat table (migration included). - GET /persona_chat/history returns a rendered transcript (tool invocations folded, is_initial flag) matching the file-chat shape. - POST /persona_chat/turn returns 202 with a turn_id; SSE replay and cancel reuse turn_replay_impl/cancel_turn_impl, extracted from the actix-attributed handlers so both route families share the logic. - POST /persona_chat/reset clears the persona transcript. - Concurrent dispatches for the same (user, persona) are rejected with 409 via an in-flight gate (InFlightPersonaTurns) whose RAII guard drops with the spawned turn task, freeing the slot on completion, error, or abort. --- .../2026-08-21-000000_persona_chat/down.sql | 1 + .../2026-08-21-000000_persona_chat/up.sql | 22 + src/ai/handlers.rs | 66 +- src/ai/insight_chat.rs | 369 +++--- src/ai/mod.rs | 5 + src/ai/persona_chat.rs | 1071 +++++++++++++++++ src/ai/turn_registry.rs | 106 ++ src/database/persona_dao.rs | 213 ++++ src/database/schema.rs | 11 + src/main.rs | 5 + src/state.rs | 26 +- 11 files changed, 1716 insertions(+), 179 deletions(-) create mode 100644 migrations/2026-08-21-000000_persona_chat/down.sql create mode 100644 migrations/2026-08-21-000000_persona_chat/up.sql create mode 100644 src/ai/persona_chat.rs diff --git a/migrations/2026-08-21-000000_persona_chat/down.sql b/migrations/2026-08-21-000000_persona_chat/down.sql new file mode 100644 index 0000000..89964c8 --- /dev/null +++ b/migrations/2026-08-21-000000_persona_chat/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS persona_chat_conversations; \ No newline at end of file diff --git a/migrations/2026-08-21-000000_persona_chat/up.sql b/migrations/2026-08-21-000000_persona_chat/up.sql new file mode 100644 index 0000000..d35cd83 --- /dev/null +++ b/migrations/2026-08-21-000000_persona_chat/up.sql @@ -0,0 +1,22 @@ +-- Open chat with persona — file-anchored insight chat is keyed by +-- (library_id, file_path). The open chat is keyed by (user_id, persona_id) +-- with a single rolling transcript per pair. No tree branching in v1 +-- (matches the locked-in "single rolling conversation per persona" scope); +-- a flat JSON blob is enough and avoids forcing a tree shape onto a +-- surface that's intentionally linear. +-- +-- `messages_json` is the same `Vec` shape the file-anchored +-- chat persists, so the SSE replay / UI rendering on the mobile client +-- can reuse the same parser without a second schema. + +CREATE TABLE persona_chat_conversations ( + user_id INTEGER NOT NULL, + persona_id TEXT NOT NULL, + messages_json TEXT NOT NULL DEFAULT '[]', + turn_count INTEGER NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + PRIMARY KEY (user_id, persona_id) +); + +CREATE INDEX idx_persona_chat_updated + ON persona_chat_conversations (user_id, updated_at DESC); \ No newline at end of file diff --git a/src/ai/handlers.rs b/src/ai/handlers.rs index 022588f..f4c0012 100644 --- a/src/ai/handlers.rs +++ b/src/ai/handlers.rs @@ -1819,6 +1819,19 @@ pub async fn turn_replay_handler( path: web::Path, query: web::Query, app_state: web::Data, +) -> HttpResponse { + turn_replay_impl(http_request, path, query, app_state).await +} + +/// Core of the SSE replay, kept attribute-free so the persona-chat routes +/// can reuse it under `/persona_chat/turn/{turn_id}`. The registry is keyed +/// on `turn_id` only, and `render_turn_info_frame` already scopes the +/// identity (persona turns carry `persona_id` and no `file_path`). +pub(crate) async fn turn_replay_impl( + http_request: HttpRequest, + path: web::Path, + query: web::Query, + app_state: web::Data, ) -> HttpResponse { use crate::ai::turn_registry::ReplayOutcome; @@ -1949,15 +1962,31 @@ pub async fn turn_replay_handler( } fn render_turn_info_frame(info: &crate::ai::turn_registry::TurnInfo) -> String { - let payload = serde_json::json!({ - "turn_id": info.turn_id, - "file_path": info.file_path, - "library_id": info.library_id, - "status": info.status.as_str(), - "total_events_pushed": info.total_events_pushed, - "buffered_count": info.buffered_count, - }); - let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()); + // Persona-scoped turns leak no `file_path` to the client — the open + // chat is keyed on `(persona_id)` only and a stray path echo from a + // quoted SMS would be a privacy regression. Insight-scoped turns + // include the path as before. + let mut payload = serde_json::Map::new(); + payload.insert("turn_id".into(), serde_json::json!(info.turn_id)); + payload.insert("library_id".into(), serde_json::json!(info.library_id)); + payload.insert("scope".into(), serde_json::json!(info.scope)); + payload.insert("status".into(), serde_json::json!(info.status.as_str())); + payload.insert( + "total_events_pushed".into(), + serde_json::json!(info.total_events_pushed), + ); + payload.insert( + "buffered_count".into(), + serde_json::json!(info.buffered_count), + ); + if info.scope == "insight" { + payload.insert("file_path".into(), serde_json::json!(info.file_path)); + } + if let Some(ref pid) = info.persona_id { + payload.insert("persona_id".into(), serde_json::json!(pid)); + } + let data = serde_json::to_string(&serde_json::Value::Object(payload)) + .unwrap_or_else(|_| "{}".to_string()); format!("event: turn_info\ndata: {}\n\n", data) } @@ -1967,6 +1996,16 @@ pub async fn cancel_turn_handler( http_request: HttpRequest, path: web::Path, app_state: web::Data, +) -> impl Responder { + cancel_turn_impl(http_request, path, app_state).await +} + +/// Core of the turn-cancel, attribute-free so the persona-chat DELETE route +/// can reuse it under `/persona_chat/turn/{turn_id}`. +pub(crate) async fn cancel_turn_impl( + http_request: HttpRequest, + path: web::Path, + app_state: web::Data, ) -> impl Responder { let turn_id = path.into_inner(); @@ -2012,11 +2051,18 @@ pub async fn cancel_turn_handler( entry.set_terminal_status(crate::ai::turn_registry::TurnStatus::Cancelled); span.set_status(Status::Ok); - HttpResponse::Ok().json(serde_json::json!({ +HttpResponse::Ok().json(serde_json::json!({ "cancelled": true })) } +// Persona-chat handlers (`/persona_chat/*`) live in `ai::persona_chat` and +// are registered in main.rs. The persona SSE replay and cancel routes are +// thin wrappers over `turn_replay_handler` + `cancel_turn_handler`: both +// are keyed on `turn_id`, not file_path, and `TurnInfo::scope` ("persona") +// lets the shared `turn_info` frame carry the right identity (persona_id, +// no file_path). + #[cfg(test)] mod turn_replay_tests { use super::{cancel_turn_handler, render_indexed_frame, turn_replay_handler}; diff --git a/src/ai/insight_chat.rs b/src/ai/insight_chat.rs index 75d050c..3bcd9fd 100644 --- a/src/ai/insight_chat.rs +++ b/src/ai/insight_chat.rs @@ -20,7 +20,7 @@ use crate::utils::{normalize_path, retry_with_backoff}; use futures::stream::{BoxStream, StreamExt}; use uuid::Uuid; -const DEFAULT_MAX_ITERATIONS: usize = 6; +pub const DEFAULT_MAX_ITERATIONS: usize = 6; /// Assumed context window when the request doesn't specify `num_ctx`. /// The llama-swap chat slots serve 20k-131k contexts and real conversations /// rarely pass ~16k tokens, so 32k keeps the truncation pass from gutting @@ -1303,7 +1303,12 @@ impl InsightChatService { } /// Agentic loop variant that pushes events to a `TurnEntry` buffer. - async fn run_streaming_agentic_loop_with_entry( + /// Same as `run_streaming_agentic_loop` but emits events to a + /// `TurnEntry` for SSE replay. Thin wrapper around the free function + /// `run_streaming_agentic_loop_with_entry` so the persona chat + /// (and any future chat-without-file surface) can reuse the loop + /// body without instantiating an `InsightChatService`. + pub async fn run_streaming_agentic_loop_with_entry( &self, backend: &ResolvedBackend, messages: &mut Vec, @@ -1315,160 +1320,19 @@ impl InsightChatService { max_iterations: usize, entry: &Arc, ) -> Result { - let mut tool_calls_made = 0usize; - let mut iterations_used = 0usize; - let mut last_prompt_eval_count: Option = None; - let mut last_eval_count: Option = None; - let mut final_content = String::new(); - - for iteration in 0..max_iterations { - // Cooperative cancellation: a DELETE flips status out of Running - // (and aborts this task). Check at the iteration boundary so an - // in-flight tool round finishes cleanly rather than mid-write. - if !entry.is_running() { - return Ok(AgenticLoopOutcome { - tool_calls_made, - iterations_used, - last_prompt_eval_count, - last_eval_count, - final_content, - cancelled: true, - }); - } - - iterations_used = iteration + 1; - let _ = entry - .push_event(ChatStreamEvent::IterationStart { - n: iterations_used, - max: max_iterations, - }) - .await; - - let mut stream = backend - .chat() - .chat_with_tools_stream(messages.clone(), tools.clone()) - .await?; - - let mut final_message: Option = None; - while let Some(ev) = stream.next().await { - let ev = ev?; - match ev { - LlmStreamEvent::TextDelta(delta) => { - let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await; - } - LlmStreamEvent::Done { - message, - prompt_eval_count, - eval_count, - } => { - last_prompt_eval_count = prompt_eval_count; - last_eval_count = eval_count; - final_message = Some(message); - break; - } - } - } - let mut response = - final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?; - - if let Some(ref mut tcs) = response.tool_calls { - for tc in tcs.iter_mut() { - if !tc.function.arguments.is_object() { - tc.function.arguments = serde_json::Value::Object(Default::default()); - } - } - } - - messages.push(response.clone()); - - if let Some(ref tool_calls) = response.tool_calls - && !tool_calls.is_empty() - { - for tool_call in tool_calls { - tool_calls_made += 1; - let call_index = tool_calls_made - 1; - let _ = entry - .push_event(ChatStreamEvent::ToolCall { - index: call_index, - name: tool_call.function.name.clone(), - arguments: tool_call.function.arguments.clone(), - }) - .await; - let cx = opentelemetry::Context::new(); - let result = self - .generator - .execute_tool( - &tool_call.function.name, - &tool_call.function.arguments, - backend, - image_base64, - normalized, - user_id, - active_persona, - &cx, - ) - .await; - let (result_preview, result_truncated) = truncate_tool_result(&result); - let _ = entry - .push_event(ChatStreamEvent::ToolResult { - index: call_index, - name: tool_call.function.name.clone(), - result: result_preview, - result_truncated, - }) - .await; - messages.push(ChatMessage::tool_result(result)); - } - continue; - } - - final_content = response.content; - break; - } - - // No-tools fallback - if final_content.is_empty() { - let synthetic_idx = push_synthetic_final_prompt(messages); - let mut stream = backend - .chat() - .chat_with_tools_stream(messages.clone(), vec![]) - .await?; - let mut final_message: Option = None; - while let Some(ev) = stream.next().await { - let ev = ev?; - match ev { - LlmStreamEvent::TextDelta(delta) => { - let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await; - } - LlmStreamEvent::Done { - message, - prompt_eval_count, - eval_count, - } => { - last_prompt_eval_count = prompt_eval_count; - last_eval_count = eval_count; - final_message = Some(message); - break; - } - } - } - let final_response = - final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?; - final_content = final_response.content.clone(); - messages.push(final_response); - remove_synthetic_final_prompt(messages, synthetic_idx); - } - - Ok(AgenticLoopOutcome { - tool_calls_made, - iterations_used, - last_prompt_eval_count, - last_eval_count, - // Strip any leaked reasoning block from the content the - // caller persists as title/summary (the raw transcript keeps it). - final_content: crate::ai::llm_client::strip_think_blocks(&final_content), - cancelled: false, - }) + crate::ai::insight_chat::run_streaming_agentic_loop_with_entry( + &self.generator, + backend, + messages, + tools, + image_base64, + normalized, + user_id, + active_persona, + max_iterations, + entry, + ) + .await } async fn run_streaming_turn( @@ -2212,17 +2076,20 @@ fn resolve_bootstrap_backend(supplied: Option<&str>) -> Result { } /// Outcome of one streaming agentic loop pass. Shared between bootstrap -/// and continuation. -struct AgenticLoopOutcome { - tool_calls_made: usize, - iterations_used: usize, - last_prompt_eval_count: Option, - last_eval_count: Option, - final_content: String, +/// and continuation. `pub` so the persona chat surface (and any future +/// chat-without-file surface) can read the result of +/// `run_streaming_agentic_loop_with_entry` without reaching back into +/// private fields. +pub struct AgenticLoopOutcome { + pub tool_calls_made: usize, + pub iterations_used: usize, + pub last_prompt_eval_count: Option, + pub last_eval_count: Option, + pub final_content: String, /// True when the loop exited early because the turn was cancelled /// (status flipped out of `Running`). Callers skip persistence and the /// terminal `Done` push — the cancel handler owns the terminal event. - cancelled: bool, + pub cancelled: bool, } /// Events emitted by `chat_turn_stream`. One stream per turn; ends after @@ -2341,7 +2208,7 @@ pub(crate) fn find_raw_cut( /// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code /// free of static globals and lets the operator change the cap by env without /// a restart in test harnesses (the running server still caches via Default). -fn env_max_iterations() -> usize { +pub fn env_max_iterations() -> usize { std::env::var("AGENTIC_CHAT_MAX_ITERATIONS") .ok() .and_then(|s| s.parse::().ok()) @@ -2395,6 +2262,174 @@ fn restore_system_content(messages: &mut [ChatMessage], original: Option } /// Append the synthetic "write your final answer" user prompt, returning the +/// Free-function form of `InsightChatService::run_streaming_agentic_loop_with_entry`. +/// Persona chat (and any future chat-without-file surface) doesn't need an +/// `InsightChatService` — it just needs the agent loop. This is the same +/// body lifted out of the `InsightChatService` impl, taking +/// `&InsightGenerator` so any caller in the crate can drive it. +/// +/// Public so the persona chat session can call into it directly. +pub async fn run_streaming_agentic_loop_with_entry( + generator: &crate::ai::insight_generator::InsightGenerator, + backend: &ResolvedBackend, + messages: &mut Vec, + tools: Vec, + image_base64: &Option, + normalized: &str, + user_id: i32, + active_persona: &str, + max_iterations: usize, + entry: &Arc, +) -> Result { + let mut tool_calls_made = 0usize; + let mut iterations_used = 0usize; + let mut last_prompt_eval_count: Option = None; + let mut last_eval_count: Option = None; + let mut final_content = String::new(); + + for iteration in 0..max_iterations { + if !entry.is_running() { + return Ok(AgenticLoopOutcome { + tool_calls_made, + iterations_used, + last_prompt_eval_count, + last_eval_count, + final_content, + cancelled: true, + }); + } + + iterations_used = iteration + 1; + let _ = entry + .push_event(ChatStreamEvent::IterationStart { + n: iterations_used, + max: max_iterations, + }) + .await; + + let mut stream = backend + .chat() + .chat_with_tools_stream(messages.clone(), tools.clone()) + .await?; + + let mut final_message: Option = None; + while let Some(ev) = stream.next().await { + let ev = ev?; + match ev { + LlmStreamEvent::TextDelta(delta) => { + let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await; + } + LlmStreamEvent::Done { + message, + prompt_eval_count, + eval_count, + } => { + last_prompt_eval_count = prompt_eval_count; + last_eval_count = eval_count; + final_message = Some(message); + break; + } + } + } + let mut response = + final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?; + + if let Some(ref mut tcs) = response.tool_calls { + for tc in tcs.iter_mut() { + if !tc.function.arguments.is_object() { + tc.function.arguments = serde_json::Value::Object(Default::default()); + } + } + } + + messages.push(response.clone()); + + if let Some(ref tool_calls) = response.tool_calls + && !tool_calls.is_empty() + { + for tool_call in tool_calls { + tool_calls_made += 1; + let call_index = tool_calls_made - 1; + let _ = entry + .push_event(ChatStreamEvent::ToolCall { + index: call_index, + name: tool_call.function.name.clone(), + arguments: tool_call.function.arguments.clone(), + }) + .await; + let cx = opentelemetry::Context::new(); + let result = generator + .execute_tool( + &tool_call.function.name, + &tool_call.function.arguments, + backend, + image_base64, + normalized, + user_id, + active_persona, + &cx, + ) + .await; + let (result_preview, result_truncated) = truncate_tool_result(&result); + let _ = entry + .push_event(ChatStreamEvent::ToolResult { + index: call_index, + name: tool_call.function.name.clone(), + result: result_preview, + result_truncated, + }) + .await; + messages.push(ChatMessage::tool_result(result)); + } + continue; + } + + final_content = response.content; + break; + } + + if final_content.is_empty() { + let synthetic_idx = push_synthetic_final_prompt(messages); + let mut stream = backend + .chat() + .chat_with_tools_stream(messages.clone(), vec![]) + .await?; + let mut final_message: Option = None; + while let Some(ev) = stream.next().await { + let ev = ev?; + match ev { + LlmStreamEvent::TextDelta(delta) => { + let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await; + } + LlmStreamEvent::Done { + message, + prompt_eval_count, + eval_count, + } => { + last_prompt_eval_count = prompt_eval_count; + last_eval_count = eval_count; + final_message = Some(message); + break; + } + } + } + let final_response = + final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?; + final_content = final_response.content.clone(); + messages.push(final_response); + remove_synthetic_final_prompt(messages, synthetic_idx); + } + + Ok(AgenticLoopOutcome { + tool_calls_made, + iterations_used, + last_prompt_eval_count, + last_eval_count, + final_content: crate::ai::llm_client::strip_think_blocks(&final_content), + cancelled: false, + }) +} + /// index the caller must later hand to [`remove_synthetic_final_prompt`]. /// Used when the agentic loop exhausts its budget: the model gets one more /// (tool-free) request, but the nudge itself must never persist — it would @@ -2638,9 +2673,9 @@ pub struct ToolInvocation { /// Soft cap for tool-result bodies returned via the history API. Keeps /// payloads small for the mobile client — verbose SMS / geocoding responses /// don't need to ship in full for inspection. -const TOOL_RESULT_PREVIEW_MAX: usize = 2000; +pub(crate) const TOOL_RESULT_PREVIEW_MAX: usize = 2000; -fn truncate_tool_result(s: &str) -> (String, bool) { +pub(crate) fn truncate_tool_result(s: &str) -> (String, bool) { if s.len() <= TOOL_RESULT_PREVIEW_MAX { (s.to_string(), false) } else { diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 3f17064..d22697a 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -7,6 +7,7 @@ pub mod gpu; pub mod handlers; pub mod insight_chat; pub mod insight_generator; +pub mod persona_chat; pub mod llamacpp; pub mod llm_client; pub mod local_llm; @@ -32,6 +33,10 @@ pub use handlers::{ get_available_models_handler, get_insight_handler, get_insight_history_handler, get_openrouter_models_handler, rate_insight_handler, turn_async_handler, turn_replay_handler, }; +pub use persona_chat::{ + persona_chat_history_handler, persona_chat_reset_handler, persona_chat_turn_handler, + persona_turn_cancel_handler, persona_turn_replay_handler, +}; pub use insight_generator::InsightGenerator; pub use llamacpp::LlamaCppClient; #[allow(unused_imports)] diff --git a/src/ai/persona_chat.rs b/src/ai/persona_chat.rs new file mode 100644 index 0000000..8fbf071 --- /dev/null +++ b/src/ai/persona_chat.rs @@ -0,0 +1,1071 @@ +//! Open chat with persona — file-anchored insight chat's sibling. +//! +//! The persona chat is the same agentic loop, but anchored on `(user_id, +//! persona_id)` instead of `(library_id, file_path)`. A single rolling +//! transcript per persona; no amend, no rewind, no branches in v1. +//! +//! 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 actix_web::{delete, get, post, web, HttpRequest, HttpResponse, Responder}; +use anyhow::{Context, Result, anyhow}; +use chrono::Utc; +use opentelemetry::KeyValue; +use opentelemetry::trace::{Span, Status, Tracer}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::sync::Mutex as TokioMutex; +use uuid::Uuid; + +use crate::ai::backend::{BackendKind, SamplingOverrides}; +use crate::ai::handlers::ReplayQuery; +use crate::ai::insight_chat::{ChatStreamEvent, DEFAULT_MAX_ITERATIONS, env_max_iterations}; +use crate::ai::insight_generator::InsightGenerator; +use crate::ai::llm_client::ChatMessage; +use crate::ai::turn_registry::{TurnEntry, TurnRegistry}; +use crate::data::Claims; +use crate::database::PersonaDao; +use crate::libraries; +use crate::otel::{extract_context_from_request, global_tracer}; +use crate::state::AppState; + +// ───────────────────────────────────────────────────────────────────── +// Errors +// ───────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum PersonaChatError { + UnknownPersona(String), + EmptyMessage, + MessageTooLong, + ConcurrentTurn, + 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::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::Db(e) => write!(f, "database error: {e}"), + } + } +} + +impl std::error::Error for PersonaChatError {} + +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 initial messages vector for a fresh persona conversation. +/// +/// Unlike the file chat (which seeds with a photo context), the open chat +/// seeds with a single system message + an opening assistant turn that +/// explains the surface ("you can ask anything your tools can reach"). +/// This keeps the agent loop's `messages[0]` = system invariant and gives +/// the LLM a stable on-screen greeting to reproduce across persona picks. +pub fn seed_messages_for_persona( + system_prompt: &str, + persona_name: &str, +) -> Vec { + let mut messages = Vec::new(); + messages.push(ChatMessage { +role: "system".to_string(), + content: system_prompt.to_string(), + tool_calls: None, + images: None, + }); + messages.push(ChatMessage { + role: "assistant".to_string(), + content: format!( + "Hi — I'm {} ready to help. Ask anything your tools can reach \ + (memories, files, SMS, calendar, places).", + persona_name + ), + tool_calls: None, + images: None, + }); + messages +} + +/// Round-trip the persisted transcript through `serde_json`. Cheap because +/// the schema is already a flat `Vec` (same shape as +/// `training_messages` on the file chat). +pub fn decode_history(raw: &str) -> Result> { + serde_json::from_str(raw).with_context(|| "failed to deserialize persona chat history") +} + +pub fn encode_history(messages: &[ChatMessage]) -> Result { + serde_json::to_string(messages) + .with_context(|| "failed to serialize persona chat history") +} + +/// Strip the seed system message + greeting assistant message before +/// persisting, so a fresh conversation never re-greets when reopened +/// (the client renders empty-state instead). +pub fn strip_seed(messages: Vec) -> Vec { + let mut out = messages; + // Drop leading system + opening assistant in order. + while out + .first() + .map(|m| m.role == "system" || (m.role == "assistant" && is_seed_greeting(&m.content))) + .unwrap_or(false) + { + out.remove(0); + } + out +} + +fn is_seed_greeting(content: &str) -> bool { + content.starts_with("Hi — I'm") && content.contains("ready to help") +} + +// ───────────────────────────────────────────────────────────────────── +// Wire shapes — camelCase out the door. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct PersonaChatTurnRequest { + /// Active persona id. Must match a row in the user's persona store + /// (built-ins seeded by migration + customs via /personas). + #[serde(rename = "personaId")] + pub persona_id: String, + /// Free-text user message. Trimmed before validation; empty input is a 400. + #[serde(rename = "userMessage")] + pub user_message: String, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub backend: Option, + #[serde(default, rename = "numCtx")] + pub num_ctx: Option, + #[serde(default)] + pub temperature: Option, + #[serde(default, rename = "topP")] + pub top_p: Option, + #[serde(default, rename = "topK")] + pub top_k: Option, + #[serde(default, rename = "minP")] + pub min_p: Option, + #[serde(default, rename = "enableThinking")] + 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, rename = "systemPrompt")] + pub system_prompt: Option, + #[serde(default)] + pub library: Option, +} + +#[derive(Debug, Serialize)] +pub struct PersonaChatHistoryView { + /// Rendered transcript — same message shape the file chat's history + /// endpoint ships, so the client's renderer is shared. + pub messages: Vec, + #[serde(rename = "turnCount")] + pub turn_count: u32, + #[serde(rename = "modelVersion")] + pub model_version: String, + pub backend: String, + #[serde(rename = "activeLeafId")] + pub active_leaf_id: i64, + #[serde(rename = "viewingBranchId")] + pub viewing_branch_id: i64, + #[serde(rename = "forkInfo")] + pub fork_info: Vec, +} + +/// 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, + #[serde(rename = "isInitial")] + pub is_initial: bool, + #[serde(rename = "tools", skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, +} + +/// Flatten the raw persisted transcript into rendered lines for the +/// history endpoint. Mirrors `render_tree_path` for a linear store: +/// `system` lines are dropped, `tool` results are folded into the +/// assistant message that follows them, and an assistant line whose only +/// payload is `tool_calls` is scaffolding (never rendered on its own). +pub fn render_flat_transcript(messages: Vec) -> Vec { + use crate::ai::insight_chat::{truncate_tool_result, ToolInvocation}; + + let mut rendered = Vec::new(); + let mut user_turns_seen = 0usize; + let mut pending_tools: Vec = Vec::new(); + let mut pending_calls: std::collections::VecDeque<(String, serde_json::Value)> = + std::collections::VecDeque::new(); + + for msg in messages { + match msg.role.as_str() { + "system" => continue, + "tool" => { + if let Some((name, arguments)) = pending_calls.pop_front() { + let (result, result_truncated) = truncate_tool_result(&msg.content); + pending_tools.push(ToolInvocation { + name, + arguments, + result, + result_truncated, + }); + } + } + "assistant" => { + let has_tool_calls = msg + .tool_calls + .as_ref() + .map(|c| !c.is_empty()) + .unwrap_or(false); + if has_tool_calls && msg.content.trim().is_empty() { + if let Some(ref tcs) = msg.tool_calls { + for tc in tcs { + pending_calls.push_back(( + tc.function.name.clone(), + tc.function.arguments.clone(), + )); + } + } + continue; + } + let tools = std::mem::take(&mut pending_tools); + pending_calls.clear(); + rendered.push(RenderedPersonaMessage { + role: "assistant".to_string(), + content: msg.content, + is_initial: false, + tools: tools + .into_iter() + .map(|t| crate::ai::handlers::HistoryToolInvocation { + name: t.name, + arguments: t.arguments, + result: t.result, + result_truncated: t.result_truncated, + }) + .collect(), + }); + } + "user" => { + let is_initial = user_turns_seen == 0; + user_turns_seen += 1; + pending_tools.clear(); + pending_calls.clear(); + rendered.push(RenderedPersonaMessage { + role: "user".to_string(), + content: msg.content, + is_initial, + tools: Vec::new(), + }); + } + _ => continue, + } + } + + rendered +} + +impl PersonaChatHistoryView { + pub fn empty() -> Self { + Self { + 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(), + } + } +} + +// ───────────────────────────────────────────────────────────────────── +// Per-persona in-flight mutex +// ───────────────────────────────────────────────────────────────────── + +/// One async mutex per (user_id, persona_id) — serializes concurrent +/// turns for the same persona so the JSON blob doesn't race. Held for +/// the lifetime of the spawned turn task. +pub type PersonaLockMap = TokioMutex>>>; + +/// Tracks in-flight persona turns so a second dispatch for the same +/// `(user_id, persona_id)` pair is rejected (HTTP 409) instead of +/// silently queueing behind the per-persona lock, where it would sit +/// "running" (and the client pending) until the first turn finishes. +/// The lock in `run_streaming_turn` stays as the last-line race guard +/// for the persisted JSON blob. +#[derive(Default)] +pub struct InFlightPersonaTurns { + map: StdMutex>, +} + +impl InFlightPersonaTurns { + pub fn new() -> Self { + Self::default() + } + + /// Claim `(user_id, persona_id)` for `turn_id`. Fails with the id of + /// the turn already in flight when the slot is taken. + pub fn claim(&self, user_id: i32, persona_id: &str, turn_id: &str) -> Result<(), String> { + let mut map = self.map.lock().expect("in-flight map poisoned"); + let key = (user_id, persona_id.to_string()); + if let Some(existing) = map.get(&key) { + return Err(existing.clone()); + } + map.insert(key, turn_id.to_string()); + Ok(()) + } + + /// Release the slot. Idempotent — removing an absent key is a no-op. + pub fn release(&self, user_id: i32, persona_id: &str) { + if let Ok(mut map) = self.map.lock() { + map.remove(&(user_id, persona_id.to_string())); + } + } +} + +/// RAII release of an in-flight slot. Moved into the spawned turn task so +/// the slot is freed when the task ends — whether by natural completion, +/// an error, or an abort from the cancel endpoint. +struct InFlightGuard { + in_flight: Arc, + user_id: i32, + persona_id: String, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.in_flight.release(self.user_id, &self.persona_id); + } +} + +// ───────────────────────────────────────────────────────────────────── +// Session — owns the agent generator, the persona DAO, and the turn +// registry, and exposes dispatch + streaming. +// ───────────────────────────────────────────────────────────────────── + +#[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()), + } + } + + /// Load the rolling transcript for `(user_id, persona_id)`. Returns an + /// empty envelope when no conversation has been started yet. + pub fn load_history( + &self, + user_id: i32, + persona_id: &str, + ) -> Result { + let cx = opentelemetry::Context::current(); + let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); + let row = dao + .get_persona_chat(&cx, user_id, persona_id) + .map_err(|e| anyhow!("failed to load persona chat: {e:?}"))?; + let Some(row) = row else { + return Ok(PersonaChatHistoryView::empty()); + }; + let messages = decode_history(&row.messages_json)?; + // Hide the seed system/greeting, then fold tool scaffolding into + // rendered lines (the client shares the file chat's renderer). + let messages = render_flat_transcript(strip_seed(messages)); + Ok(PersonaChatHistoryView { + turn_count: row.turn_count as u32, + messages, + ..PersonaChatHistoryView::empty() + }) + } + + /// 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 persona_id = req.persona_id.trim().to_string(); + if persona_id.is_empty() { + return Err(PersonaChatError::UnknownPersona(persona_id)); + } + + let cx = opentelemetry::Context::current(); + let persona = { + let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); + dao.get_persona(&cx, user_id, &persona_id) + .map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))? + }; + let persona = persona.ok_or_else(|| PersonaChatError::UnknownPersona(persona_id.clone()))?; + + let turn_id = Uuid::new_v4().to_string(); + self.in_flight + .claim(user_id, &persona_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 turn_id_for_task = turn_id.clone(); + let req_for_task = PersonaChatTurnRequest { + user_message: trimmed.clone(), + ..req + }; + let in_flight_guard = InFlightGuard { + in_flight: self.in_flight.clone(), + user_id, + persona_id: persona_id.clone(), + }; + let handle = tokio::spawn(async move { + let tracer = global_tracer(); + let mut span = tracer.start("ai.persona_chat.turn.execute"); + span.set_attribute(KeyValue::new("turn_id", turn_id_for_task.clone())); + span.set_attribute(KeyValue::new("persona_id", persona_id.clone())); + span.set_attribute(KeyValue::new("library_id", library_id as i64)); + + let result = svc + .run_streaming_turn( + persona_for_task, + req_for_task, + entry_clone.clone(), + user_id, + library_id, + ) + .await; + match result { + Ok(()) => { + span.set_attribute(KeyValue::new("status", "done")); + span.set_status(Status::Ok); + } + Err(e) => { + span.set_attribute(KeyValue::new("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); + }); + + 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, + ) -> Result<()> { + // Per-persona mutex — serializes concurrent turns so the JSON + // blob doesn't race. + let lock_key = (user_id, persona.persona_id.clone()); + let lock = { + let mut locks = self.chat_locks.lock().await; + locks + .entry(lock_key.clone()) + .or_insert_with(|| Arc::new(TokioMutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + // Build the messages vector from persisted history. + let cx = opentelemetry::Context::current(); + let row = { + let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); + dao.get_persona_chat(&cx, user_id, &persona.persona_id) + .map_err(|e| anyhow!("failed to load persona chat: {e:?}"))? + }; + + let mut messages = match row { + Some(r) => decode_history(&r.messages_json)?, + None => { + let prompt = resolve_persona_system_prompt(Some(&persona)) + .unwrap_or_else(|| "You are a helpful assistant.".to_string()); + seed_messages_for_persona(&prompt, &persona.name) + } + }; + + // 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, env_max_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(); + + // 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. + let gate_opts = self + .generator + .current_gate_opts_for_persona(backend.images_inline, Some((user_id, &persona.persona_id))); + let tools = InsightGenerator::build_tool_definitions(gate_opts); + + // 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 + }; + + let base_count = 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, + max_iterations, + &entry, + ) + .await + .map_err(|e| anyhow!("persona chat loop failed: {e:?}"))?; + + // 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()); + } + } + + // Persist the updated transcript. `messages[0..base_count]` is the + // persisted slice; everything past that is the new turn. + let json = encode_history(&messages)?; + let turn_count = messages.len().saturating_sub(base_count) as i32; + { + let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); + dao.upsert_persona_chat(&cx, user_id, &persona.persona_id, &json, turn_count, Utc::now().timestamp_millis()) + .map_err(|e| anyhow!("failed to persist persona chat: {e:?}"))?; + } + + let _ = entry + .push_event(ChatStreamEvent::Done { + tool_calls_made: outcome.tool_calls_made, + iterations_used: outcome.iterations_used, + truncated: false, + 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(()) + } + + /// Wipe the rolling transcript for `(user_id, persona_id)`. Reserved + /// for a future "New conversation" affordance; the v1 UI doesn't call + /// this but the endpoint is shipped so the contract is stable. + pub fn reset(&self, user_id: i32, persona_id: &str) -> Result<()> { + let cx = opentelemetry::Context::current(); + let mut dao = self.persona_dao.lock().expect("persona_dao poisoned"); + dao.clear_persona_chat(&cx, user_id, persona_id) + .map_err(|e| anyhow!("failed to reset persona chat: {e:?}"))?; + Ok(()) + } +} + +// ───────────────────────────────────────────────────────────────────── +// 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 persona_id: String, + #[serde(default)] + #[allow(dead_code)] + pub library: Option, +} + +/// Body for POST /persona_chat/reset — wipes the rolling transcript for +/// one persona. +#[derive(Debug, Deserialize)] +pub struct PersonaChatResetRequest { + #[serde(rename = "personaId")] + pub persona_id: String, +} + +/// 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.persona_id) + { + Ok(view) => HttpResponse::Ok().json(view), + Err(e) => { + log::error!("persona chat history load failed: {e}"); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("{e}") + })) + } + } +} + +/// 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")] +pub async fn persona_chat_turn_handler( + http_request: HttpRequest, + claims: Claims, + request: web::Json, + app_state: web::Data, +) -> impl Responder { + let parent_context = extract_context_from_request(&http_request); + let tracer = global_tracer(); + let mut span = tracer.start_with_context("http.persona_chat.turn", &parent_context); + span.set_attribute(KeyValue::new("persona_id", request.persona_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 HttpResponse::BadRequest().json(serde_json::json!({ + "error": format!("invalid library: {e}") + })); + } + }; + + 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(KeyValue::new("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}"))); + match &e { + PersonaChatError::UnknownPersona(_) => HttpResponse::NotFound(), + PersonaChatError::EmptyMessage | PersonaChatError::MessageTooLong => { + HttpResponse::BadRequest() + } + PersonaChatError::ConcurrentTurn => HttpResponse::Conflict(), + PersonaChatError::Db(_) => { + log::error!("persona chat dispatch failed: {e}"); + HttpResponse::InternalServerError() + } + } + .json(serde_json::json!({ "error": format!("{e}") })) + } + } +} + +/// POST /persona_chat/reset — wipe the rolling transcript for one +/// persona. Shipped so the contract is stable; the v1 UI has no +/// "New conversation" button yet. +#[post("/persona_chat/reset")] +pub async fn persona_chat_reset_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 + .reset(user_id, &request.persona_id) + { + Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "reset": true })), + Err(e) => { + log::error!("persona chat reset failed: {e}"); + HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("{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( + http_request: HttpRequest, + path: web::Path, + query: web::Query, + app_state: web::Data, +) -> HttpResponse { + crate::ai::handlers::turn_replay_impl(http_request, 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( + http_request: HttpRequest, + path: web::Path, + app_state: web::Data, +) -> impl Responder { + crate::ai::handlers::cancel_turn_impl(http_request, 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_includes_system_and_greeting() { + let msgs = seed_messages_for_persona("Be terse.", "Journal"); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0].role, "system"); + assert_eq!(msgs[0].content, "Be terse."); + assert_eq!(msgs[1].role, "assistant"); + assert!(msgs[1].content.starts_with("Hi — I'm")); + assert!(msgs[1].content.contains("Journal")); + } + + #[test] + fn strip_seed_drops_leading_system_and_greeting() { + let mut msgs = seed_messages_for_persona("sys", "Default"); + msgs.push(ChatMessage::user("hi".to_string())); + msgs.push(assistant_msg("hello")); + msgs.push(ChatMessage::user("how are you?".to_string())); + let stripped = strip_seed(msgs); + assert_eq!(stripped.len(), 3); + assert_eq!(stripped[0].role, "user"); + assert_eq!(stripped[0].content, "hi"); + assert_eq!(stripped[2].role, "user"); + assert_eq!(stripped[2].content, "how are you?"); + } + + #[test] + fn strip_seed_preserves_an_unrelated_leading_assistant() { + // If the first message isn't the seed greeting, leave it alone — + // strip_seed is conservative. + let msgs = vec![ + assistant_msg("carry-over from last open"), + ChatMessage::user("hi".to_string()), + ]; + let stripped = strip_seed(msgs); + assert_eq!(stripped.len(), 2); + assert_eq!(stripped[0].content, "carry-over from last open"); + } + + #[test] + fn encode_decode_round_trips_through_json() { + let msgs = vec![ + ChatMessage::system("sys".to_string()), + ChatMessage::user("hi".to_string()), + assistant_msg("hello"), + ]; + let json = encode_history(&msgs).unwrap(); + let back = decode_history(&json).unwrap(); + assert_eq!(back.len(), msgs.len()); + assert_eq!(back[1].role, "user"); + assert_eq!(back[2].content, "hello"); + } + + #[test] + fn decode_history_rejects_garbage() { + let err = decode_history("not-json").unwrap_err(); + assert!(err.to_string().contains("failed to deserialize")); + } + + #[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 { + persona_id: "default".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 { + persona_id: "default".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(1, "default", "t1").is_ok()); + + // A second dispatch for the same (user, persona) 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(1, "default", "t2").unwrap_err(), "t1"); + assert_eq!(in_flight.claim(1, "default", "t3").unwrap_err(), "t1"); + + // Other personas and users are unaffected. + assert!(in_flight.claim(1, "journal", "t4").is_ok()); + assert!(in_flight.claim(2, "default", "t5").is_ok()); + + // Releasing frees the slot for a fresh dispatch. + in_flight.release(1, "default"); + assert!(in_flight.claim(1, "default", "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(1, "default", "t-guard").is_ok()); + { + let _guard = InFlightGuard { + in_flight, + user_id: 1, + persona_id: "default".to_string(), + }; + assert_eq!(tracker.claim(1, "default", "other").unwrap_err(), "t-guard"); + } + // Guard dropped → slot free again. + assert!(tracker.claim(1, "default", "t7").is_ok()); + } +} \ No newline at end of file diff --git a/src/ai/turn_registry.rs b/src/ai/turn_registry.rs index 2a5d432..fb82638 100644 --- a/src/ai/turn_registry.rs +++ b/src/ai/turn_registry.rs @@ -52,6 +52,10 @@ pub struct TurnInfo { pub turn_id: String, pub file_path: String, pub library_id: i32, + /// Persona id for persona-scoped turns. None for insight-scoped turns. + pub persona_id: Option, + /// `"insight"` (file-anchored chat) | `"persona"` (open chat). + pub scope: String, pub status: TurnStatus, pub total_events_pushed: u32, pub buffered_count: u32, @@ -78,8 +82,20 @@ pub enum ReplayOutcome { /// replay connections (readers). pub struct TurnEntry { pub turn_id: String, + /// Stable identity used to scope the turn. For insight chat this is + /// `(file_path, library_id)`. For persona chat it is `(persona_id)`. + /// The generic fields stay populated regardless of scope so the SSE + /// `turn_info` frame can always surface a useful label — a future + /// "list active turns" debugging surface won't need to discriminate. pub file_path: String, pub library_id: i32, + /// Persona id for persona-scoped turns (`scope == "persona"`). None for + /// the existing insight-scoped turns. The field is the cheapest way to + /// let the SSE `turn_info` payload round-trip without a parallel map. + pub persona_id: Option, + /// `"insight"` (default — file-anchored chat) | `"persona"` (open chat). + /// Stable string so the SSE `turn_info` payload can carry it as-is. + pub scope: String, /// Shared event buffer — multiple SSE connections can read independently. /// Each connection tracks its own `skip_before` offset. events: Mutex>, @@ -100,10 +116,35 @@ pub struct TurnEntry { impl TurnEntry { pub fn new(turn_id: String, file_path: String, library_id: i32) -> Self { + Self::with_scope(turn_id, file_path, library_id, None, "insight") + } + + /// Persona-scoped constructor. `file_path` is unused by the persona + /// chat loop but kept on the struct so the SSE `turn_info` frame has + /// the same shape across scopes. + pub fn new_persona(turn_id: String, persona_id: String, library_id: i32) -> Self { + Self::with_scope( + turn_id, + String::new(), + library_id, + Some(persona_id), + "persona", + ) + } + + fn with_scope( + turn_id: String, + file_path: String, + library_id: i32, + persona_id: Option, + scope: &str, + ) -> Self { Self { turn_id, file_path, library_id, + persona_id, + scope: scope.to_string(), events: Mutex::new(Vec::new()), total_events_pushed: AtomicU32::new(0), base_index: AtomicU32::new(0), @@ -170,6 +211,8 @@ impl TurnEntry { turn_id: self.turn_id.clone(), file_path: self.file_path.clone(), library_id: self.library_id, + persona_id: self.persona_id.clone(), + scope: self.scope.clone(), status: self.status.load(Ordering::Relaxed).into(), total_events_pushed: total, buffered_count: buffered, @@ -745,4 +788,67 @@ mod tests { let from_base = events_of(entry.replay_from(5).await); assert_eq!(from_base.len(), MAX_BUFFERED_EVENTS); } + + // ── Persona-scope regression guards ─────────────────────────── + // + // The open-chat surface relies on `scope == "persona"` and the + // optional `persona_id` surviving a round-trip through `info()`. + // A future refactor that hard-codes "insight" or drops the field + // would silently break the SSE `turn_info` payload — guard it. + + #[tokio::test] + async fn turn_entry_supports_persona_scope_label() { + let entry = Arc::new(TurnEntry::new_persona( + "tp1".to_string(), + "journal".to_string(), + 1, + )); + assert_eq!(entry.scope, "persona"); + assert_eq!(entry.persona_id.as_deref(), Some("journal")); + let info = entry.info().await; + assert_eq!(info.scope, "persona"); + assert_eq!(info.persona_id.as_deref(), Some("journal")); + // File-anchored fields stay blank — the persona chat has no file. + assert!(info.file_path.is_empty()); + } + + #[tokio::test] + async fn turn_info_payload_for_persona_does_not_leak_file_path() { + // Defensive: even if some future code path accidentally populates + // file_path on a persona entry, the SSE `turn_info` payload + // should not surface it to clients. Today file_path is always + // empty for persona entries, so we assert that contract. + let entry = Arc::new(TurnEntry::new_persona( + "tp2".to_string(), + "default".to_string(), + 1, + )); + let info = entry.info().await; + let json = serde_json::to_string(&serde_json::json!({ + "turn_id": info.turn_id, + "persona_id": info.persona_id, + "scope": info.scope, + "status": info.status.as_str(), + "total_events_pushed": info.total_events_pushed, + "buffered_count": info.buffered_count, + })) + .unwrap(); + // Privacy: a persona chat reply that quotes an SMS must not echo + // the file path that message originally surfaced from. + assert!( + !json.contains("file_path"), + "persona turn_info payload should not leak file_path: {json}" + ); + } + + #[tokio::test] + async fn insight_scope_defaults_when_using_legacy_constructor() { + // The legacy `TurnEntry::new` constructor (used by the existing + // file-anchored insight chat) must still report scope="insight" + // so callers can discriminate without a parallel map. + let entry = Arc::new(TurnEntry::new("t-insight".into(), "/p.jpg".into(), 1)); + let info = entry.info().await; + assert_eq!(info.scope, "insight"); + assert!(info.persona_id.is_none()); + } } diff --git a/src/database/persona_dao.rs b/src/database/persona_dao.rs index 6ceb2af..af751a6 100644 --- a/src/database/persona_dao.rs +++ b/src/database/persona_dao.rs @@ -10,6 +10,19 @@ use crate::database::schema; use crate::database::{DbError, DbErrorKind, connect}; use crate::otel::trace_db_call; +/// One row of the persona-chat transcript. Lives in +/// `persona_chat_conversations` (one row per `(user_id, persona_id)`). +/// +/// `turn_count` is the number of new turn-rows since the last persisted +/// slice — used by the SSE `done` event for stats dashboards. The actual +/// transcript is the `messages_json` blob. +#[derive(Clone, Debug)] +pub struct PersonaChatRow { + pub messages_json: String, + pub turn_count: i32, + pub updated_at: i64, +} + /// Patch shape for update_persona. None = leave field alone. Built-ins are /// allowed to flip `include_all_memories` but should reject name/prompt /// edits at the handler layer (built-in copy lives in the migration). @@ -80,6 +93,43 @@ pub trait PersonaDao: Sync + Send { user_id: i32, personas: &[ImportPersona], ) -> Result; + + // ── Persona-chat (open chat with persona) persistence ─────────── + // + // Keyed by `(user_id, persona_id)` with a single rolling transcript + // per pair. No tree branching in v1 — matches the locked-in scope. + + /// Fetch the rolling transcript for one `(user, persona)`. None when + /// the user has never started a conversation with this persona. + fn get_persona_chat( + &mut self, + cx: &opentelemetry::Context, + user_id: i32, + persona_id: &str, + ) -> Result, DbError>; + + /// Upsert (create-or-replace) the rolling transcript. Called once per + /// completed turn with the full new `messages_json`. The `turn_count` + /// is the number of user/assistant pairs added in this write. + fn upsert_persona_chat( + &mut self, + cx: &opentelemetry::Context, + user_id: i32, + persona_id: &str, + messages_json: &str, + turn_count: i32, + updated_at: i64, + ) -> Result<(), DbError>; + + /// Wipe the rolling transcript. Reserved for a future "New + /// conversation" affordance; the row itself stays (so a subsequent + /// get returns None, not 404). + fn clear_persona_chat( + &mut self, + cx: &opentelemetry::Context, + user_id: i32, + persona_id: &str, + ) -> Result<(), DbError>; } pub struct SqlitePersonaDao { @@ -296,6 +346,96 @@ impl PersonaDao for SqlitePersonaDao { }) .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) } + + fn get_persona_chat( + &mut self, + cx: &opentelemetry::Context, + uid: i32, + pid: &str, + ) -> Result, DbError> { + trace_db_call(cx, "query", "get_persona_chat", |_span| { + use schema::persona_chat_conversations::dsl::*; + let mut conn = self.connection.lock().expect("PersonaDao lock"); + persona_chat_conversations + .filter(user_id.eq(uid)) + .filter(persona_id.eq(pid)) + .select((messages_json, turn_count, updated_at)) + .first::<(String, i32, i64)>(conn.deref_mut()) + .optional() + .map(|opt| { + opt.map(|(m, t, u)| PersonaChatRow { + messages_json: m, + turn_count: t, + updated_at: u, + }) + }) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn upsert_persona_chat( + &mut self, + cx: &opentelemetry::Context, + uid: i32, + pid: &str, + json: &str, + count: i32, + updated_at: i64, + ) -> Result<(), DbError> { + trace_db_call(cx, "upsert", "upsert_persona_chat", |_span| { + let mut conn = self.connection.lock().expect("PersonaDao lock"); + // INSERT OR REPLACE on the (user_id, persona_id) PRIMARY KEY — + // single rolling transcript, so a new write always supersedes + // the prior one in full. The mobile hook serialises turns with + // a per-persona mutex, so this never races. Plain + // `sql_query` sidesteps a Diesel type-recursion blow-up that + // hits `insert_into(...).on_conflict(...).do_update().set(...)` + // with this many typed columns. + diesel::sql_query( + "INSERT INTO persona_chat_conversations \ + (user_id, persona_id, messages_json, turn_count, updated_at) \ + VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT(user_id, persona_id) DO UPDATE SET \ + messages_json = excluded.messages_json, \ + turn_count = excluded.turn_count, \ + updated_at = excluded.updated_at", + ) + .bind::(uid) + .bind::(pid) + .bind::(json) + .bind::(count) + .bind::(updated_at) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Upsert error: {}", e))?; + Ok(()) + }) + .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) + } + + fn clear_persona_chat( + &mut self, + cx: &opentelemetry::Context, + uid: i32, + pid: &str, + ) -> Result<(), DbError> { + trace_db_call(cx, "delete", "clear_persona_chat", |_span| { + use schema::persona_chat_conversations::dsl::*; + let mut conn = self.connection.lock().expect("PersonaDao lock"); + // Delete the row entirely so the next get returns None. + // The next send will INSERT a fresh seed (system + greeting) + // via the same code path a brand-new persona uses. + diesel::delete( + persona_chat_conversations + .filter(user_id.eq(uid)) + .filter(persona_id.eq(pid)), + ) + .execute(conn.deref_mut()) + .map_err(|e| anyhow::anyhow!("Delete error: {}", e))?; + Ok(()) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } } #[cfg(test)] @@ -444,4 +584,77 @@ mod tests { .unwrap(); assert!(updated.include_all_memories); } + + // ── Persona-chat DAO tests ───────────────────────────────────── + + #[test] + fn persona_chat_get_returns_none_for_never_started() { + let cx = opentelemetry::Context::new(); + let (mut dao, uid) = dao_with_user("p1"); + let row = dao.get_persona_chat(&cx, uid, "default").unwrap(); + assert!(row.is_none()); + } + + #[test] + fn persona_chat_upsert_then_get_round_trip() { + let cx = opentelemetry::Context::new(); + let (mut dao, uid) = dao_with_user("p2"); + dao.upsert_persona_chat(&cx, uid, "journal", "[]", 1, 100) + .unwrap(); + let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap(); + assert_eq!(row.messages_json, "[]"); + assert_eq!(row.turn_count, 1); + assert_eq!(row.updated_at, 100); + } + + #[test] + fn persona_chat_upsert_replaces_existing_row() { + let cx = opentelemetry::Context::new(); + let (mut dao, uid) = dao_with_user("p3"); + dao.upsert_persona_chat(&cx, uid, "journal", "first", 1, 100).unwrap(); + dao.upsert_persona_chat(&cx, uid, "journal", "second", 2, 200).unwrap(); + let row = dao.get_persona_chat(&cx, uid, "journal").unwrap().unwrap(); + assert_eq!(row.messages_json, "second"); + assert_eq!(row.turn_count, 2); + assert_eq!(row.updated_at, 200); + // Single rolling transcript → exactly one row per (user, persona). + } + + #[test] + fn persona_chat_isolation_between_users() { + let cx = opentelemetry::Context::new(); + let (mut dao, uid1) = dao_with_user("u1"); + let uid2: i32 = { + let conn = dao.connection.clone(); + use crate::database::schema::users::dsl as u; + diesel::insert_into(u::users) + .values((u::username.eq("u2"), u::password.eq("x"))) + .execute(conn.lock().unwrap().deref_mut()) + .unwrap(); + u::users + .filter(u::username.eq("u2")) + .select(u::id) + .first(conn.lock().unwrap().deref_mut()) + .unwrap() + }; + dao.upsert_persona_chat(&cx, uid1, "default", "u1-row", 1, 1).unwrap(); + dao.upsert_persona_chat(&cx, uid2, "default", "u2-row", 1, 2).unwrap(); + assert_eq!( + dao.get_persona_chat(&cx, uid1, "default").unwrap().unwrap().messages_json, + "u1-row" + ); + assert_eq!( + dao.get_persona_chat(&cx, uid2, "default").unwrap().unwrap().messages_json, + "u2-row" + ); + } + + #[test] + fn persona_chat_clear_wipes_the_row_so_get_returns_none() { + let cx = opentelemetry::Context::new(); + let (mut dao, uid) = dao_with_user("p4"); + dao.upsert_persona_chat(&cx, uid, "default", "[]", 1, 1).unwrap(); + dao.clear_persona_chat(&cx, uid, "default").unwrap(); + assert!(dao.get_persona_chat(&cx, uid, "default").unwrap().is_none()); + } } diff --git a/src/database/schema.rs b/src/database/schema.rs index 846542d..8eeee1c 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -171,6 +171,16 @@ diesel::table! { } } +diesel::table! { + persona_chat_conversations (user_id, persona_id) { + user_id -> Integer, + persona_id -> Text, + messages_json -> Text, + turn_count -> Integer, + updated_at -> BigInt, + } +} + diesel::table! { personas (id) { id -> Integer, @@ -345,6 +355,7 @@ diesel::allow_tables_to_appear_in_same_query!( insight_generation_jobs, libraries, location_history, + persona_chat_conversations, personas, persons, photo_insights, diff --git a/src/main.rs b/src/main.rs index 9d27f2e..dbd633c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -382,6 +382,11 @@ fn main() -> std::io::Result<()> { .service(ai::turn_async_handler) .service(ai::turn_replay_handler) .service(ai::cancel_turn_handler) + .service(ai::persona_chat_history_handler) + .service(ai::persona_chat_turn_handler) + .service(ai::persona_chat_reset_handler) + .service(ai::persona_turn_replay_handler) + .service(ai::persona_turn_cancel_handler) .service(ai::rate_insight_handler) .service(ai::export_training_data_handler) .service(ai::tts_speech_handler) diff --git a/src/state.rs b/src/state.rs index 33e8e3f..3f456e7 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,6 +4,7 @@ use crate::ai::face_client::FaceClient; use crate::ai::insight_chat::{ChatLockMap, InsightChatService}; use crate::ai::llamacpp::LlamaCppClient; use crate::ai::openrouter::OpenRouterClient; +use crate::ai::persona_chat::PersonaChatSession; use crate::ai::turn_registry::TurnRegistry; use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient}; use crate::database::{ @@ -84,6 +85,9 @@ pub struct AppState { pub insight_generator: InsightGenerator, /// Chat continuation service. Hold an Arc so handlers can clone cheaply. pub insight_chat: Arc, + /// Open chat with persona service. Same shape as insight_chat but + /// anchored on (user_id, persona_id) instead of (library_id, file_path). + pub persona_chat_session: Arc, pub turn_registry: Arc, pub face_client: FaceClient, pub clip_client: ClipClient, @@ -133,6 +137,7 @@ impl AppState { sms_client: SmsApiClient, insight_generator: InsightGenerator, insight_chat: Arc, + persona_chat_session: Arc, turn_registry: Arc, preview_dao: Arc>>, face_client: FaceClient, @@ -194,6 +199,7 @@ impl AppState { sms_client, insight_generator, insight_chat, + persona_chat_session, turn_registry, face_client, clip_client, @@ -316,7 +322,7 @@ impl Default for AppState { tag_dao.clone(), face_dao.clone(), knowledge_dao, - persona_dao, + persona_dao.clone(), libraries_vec.clone(), ); @@ -330,6 +336,13 @@ impl Default for AppState { chat_locks, )); + // Open chat with persona: reuses the generator + persona DAO. + let persona_chat_session = Arc::new(PersonaChatSession::new( + Arc::new(insight_generator.clone()), + persona_dao.clone(), + Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + )); + // Turn registry for reconnectable chat turns. 5-minute timeout for // stale turns (background cleaner drops entries older than this). let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS") @@ -360,6 +373,7 @@ impl Default for AppState { sms_client, insight_generator, insight_chat, + persona_chat_session, turn_registry, preview_dao, face_client, @@ -528,7 +542,7 @@ impl AppState { tag_dao.clone(), face_dao.clone(), knowledge_dao, - persona_dao, + persona_dao.clone(), vec![test_lib], ); @@ -540,6 +554,13 @@ impl AppState { chat_locks, )); + // Open chat with persona (test). + let persona_chat_session = Arc::new(PersonaChatSession::new( + Arc::new(insight_generator.clone()), + persona_dao.clone(), + Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + )); + // Turn registry for test state. let turn_registry = Arc::new(TurnRegistry::new(300)); @@ -571,6 +592,7 @@ impl AppState { sms_client, insight_generator, insight_chat, + persona_chat_session, turn_registry, preview_dao, FaceClient::new(None), // disabled in test