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

+106
View File
@@ -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<String>,
/// `"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<String>,
/// `"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<Vec<ChatStreamEvent>>,
@@ -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<String>,
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());
}
}