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:
1 parent
649863a700
commit
3b9d985025
11 files changed
+1716
-179
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user