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

+56 -10
View File
@@ -1819,6 +1819,19 @@ pub async fn turn_replay_handler(
path: web::Path<String>,
query: web::Query<ReplayQuery>,
app_state: web::Data<AppState>,
) -> 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<String>,
query: web::Query<ReplayQuery>,
app_state: web::Data<AppState>,
) -> 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<String>,
app_state: web::Data<AppState>,
) -> 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<String>,
app_state: web::Data<AppState>,
) -> 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};