feat: multiple persona conversations with branching and generated titles

Persona chat stored a flat Vec<ChatMessage> keyed on (user_id, persona_id),
which meant one rolling transcript per persona and no way to revisit a turn.
This moves it onto the same ChatHistoryStore tree the file chat uses and
gives conversations their own identity.

Storage
- messages_json now holds a serialized ChatHistoryStore. Reads accept the
  old flat array and upgrade it in place, so existing transcripts survive
  without a data migration. The upgrade drops the v1 seed greeting, which
  the flat renderer hid but the tree renderer would surface as a bubble the
  user has never seen.
- New migration re-keys persona_chat_conversations on an opaque
  conversation_id and adds title + created_at, so one persona can hold any
  number of separate threads. Every DAO read and write is scoped by user_id
  as well: a conversation id is a bearer token for someone's transcript and
  must never grant access on its own.
- The per-conversation lock and in-flight turn slot key on conversation_id,
  so two threads with the same persona can run turns concurrently.

Endpoints
- POST/DELETE /persona_chat/conversations — start and remove a thread,
  replacing /persona_chat/reset.
- GET /persona_chat/conversations — the chat list, with snippet and counts
  derived from each tree's active branch.
- POST /persona_chat/rewind, POST /persona_chat/switch-branch,
  GET /persona_chat/branches — rewind and fork, mirroring the file chat.
  Index 0 is rewindable here (it is the user's own first question, not a
  synthetic prompt) and re-anchors on the seed node.
- history/turn/rewind/switch-branch/branches all key on conversation_id;
  history gained branch_id and now returns real fork_info, active_leaf_id
  and viewing_branch_id instead of placeholders.

The turn body no longer carries a persona at all — it is read from the
stored conversation, so a stale client cannot swap a thread's voice midway.

Titles
After the first turn persists, the conversation is named from its opening
exchange on the same backend the turn ran on. Small models wrap titles in
quotes, prefix them with "Title:" and append explanations, so sanitize_title
strips all of that and truncates on a character boundary. Any failure falls
back to the user's opening question. Generation runs after persistence: a
failed title must not cost the turn.

Fixes found along the way
- insight_chat: both file-chat turn paths captured path.len() before
  apply_context_budget drained messages out of the middle, then sliced
  messages[path_len..] for the new tree nodes. Once truncation fired that
  dropped the user turn from the tree or panicked on an out-of-range start
  index. Now read after the budget pass as history_len.
- Persona chat had no context budget at all and hardcoded truncated: false
  in the done frame, so a rolling transcript grew unbounded.
- A cancelled turn persisted a half-finished transcript and pushed a second
  terminal frame; it now returns early like the file chat.
- The seeded system prompt was frozen at conversation creation, so editing a
  persona never reached a thread already in flight. Re-resolved per turn.
- turn_count was overwritten each write with the per-turn message delta;
  it is now the cumulative assistant-turn count on the active branch.
- is_initial is always false: the file chat reserves it for its synthetic
  "describe this photo" prompt, and marking a persona chat's first question
  with it made the opening reply impossible to regenerate.

600 lib tests pass, clippy --all-targets clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-08-25 18:23:10 -04:00
parent 883e2a0e1b
commit 65cceaa67c
13 changed files with 1848 additions and 455 deletions
+17 -8
View File
@@ -30,11 +30,11 @@ pub const DEFAULT_MAX_ITERATIONS: usize = 6;
const DEFAULT_NUM_CTX: i32 = 32768;
/// Headroom reserved for the model's response, deducted from the context
/// budget when deciding whether to truncate the replayed history.
const RESPONSE_HEADROOM_TOKENS: usize = 2048;
pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
/// Cheap byte-to-token approximation used by the truncation pass. The real
/// tokenization is model-specific; this avoids carrying tiktoken just for a
/// soft bound.
const BYTES_PER_TOKEN: usize = 4;
pub(crate) const BYTES_PER_TOKEN: usize = 4;
/// Flat token cost charged per inlined image in the truncation budget. A
/// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on
/// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of
@@ -335,6 +335,13 @@ impl InsightChatService {
if truncated {
span.set_attribute(KeyValue::new("history_truncated", true));
}
// Everything in `messages` at this point is replayed history that is
// ALREADY in the tree; only what the turn appends past this mark
// becomes new nodes. Captured after the budget pass, not from
// `path.len()`: truncation drains from the middle, so `path.len()`
// over-counts and the slice below would either swallow the new user
// turn or panic on an out-of-range start index.
let history_len = messages.len();
// 7. Append the new user turn.
messages.push(ChatMessage::user(req.user_message.clone()));
@@ -461,8 +468,7 @@ impl InsightChatService {
// relying on store_insight to flip prior rows' is_current=false.
// Append new messages (this turn's user + assistant exchanges) as
// tree nodes chained from the previous active_leaf_id.
let path_len = path.len();
let new_messages = messages[path_len..].to_vec();
let new_messages = messages[history_len..].to_vec();
let mut parent_id = Some(store.active_leaf_id);
for msg in &new_messages {
let new_id = store.append_node(parent_id, msg.clone());
@@ -942,7 +948,6 @@ impl InsightChatService {
let path = store
.path_to_leaf(store.active_leaf_id)
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
let path_len = path.len();
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
let stored_backend = insight.backend.clone();
@@ -1003,6 +1008,10 @@ impl InsightChatService {
if truncated {
let _ = entry.push_event(ChatStreamEvent::Truncated).await;
}
// See the note in `chat_turn`: the new-node boundary must be read
// after the budget pass, because truncation drains replayed history
// out of the middle of `messages`.
let history_len = messages.len();
messages.push(ChatMessage::user(req.user_message.clone()));
@@ -1046,7 +1055,7 @@ impl InsightChatService {
}
// Append new messages as tree nodes.
let new_messages = messages[path_len..].to_vec();
let new_messages = messages[history_len..].to_vec();
let mut parent_id = Some(store.active_leaf_id);
for msg in &new_messages {
let new_id = store.append_node(parent_id, msg.clone());
@@ -2219,7 +2228,7 @@ pub fn env_max_iterations() -> usize {
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
/// window for the truncation budget when the request omits `num_ctx`. Same
/// no-static-global rationale as `env_max_iterations` above.
fn env_default_num_ctx() -> i32 {
pub(crate) fn env_default_num_ctx() -> i32 {
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
.ok()
.and_then(|s| s.parse::<i32>().ok())
@@ -2534,7 +2543,7 @@ pub struct ForkInfo {
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to
/// the next rendered message. Detecting forks only on rendered nodes would miss
/// regenerations where the model replied with a tool call.
fn render_tree_path(
pub(crate) fn render_tree_path(
store: &ChatHistoryStore,
path: &[&StoredChatNode],
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {