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.
This commit is contained in:
Cameron Cordes committed 2026-08-24 21:25:48 -04:00
1 parent 649863a700
commit 3b9d985025
11 files changed
+1716 -179

No files matched your search

+213
View File
@@ -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<usize, DbError>;
// ── 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<Option<PersonaChatRow>, 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<Option<PersonaChatRow>, 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::<diesel::sql_types::Integer, _>(uid)
.bind::<diesel::sql_types::Text, _>(pid)
.bind::<diesel::sql_types::Text, _>(json)
.bind::<diesel::sql_types::Integer, _>(count)
.bind::<diesel::sql_types::BigInt, _>(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());
}
}