fix(persona-chat): review fixes before merge

- Thread tools are bound server-side to the chat's own thread. The model
  used to pass contact_id, so practice mode could read any conversation it
  named; a model-supplied contact_id is now only checked, never trusted, and
  the prompts no longer hand it one.
- Practice chats need no persona. They're stored under PRACTICE_PERSONA_ID
  and run with an inert stand-in, so deleting or editing personas can't hide
  or break them. persona_id is optional on create for as_contact.
- The per-thread list filters in SQL (list_persona_chats_for_contact, served
  by idx_persona_chat_anchor_contact) instead of decoding every transcript.
- fetch_mms_image checks the content type and a 20 MB cap before reading
  the body, so a video attachment isn't downloaded in full.
- get_photos_near_time unions tags over every path sharing the content, per
  the multi-library model.
- look_at_photo only opens indexed photos, so an excluded folder can't be
  read by guessing a path.
- One date parser: get_photos_near_time uses date_info::parse_center.
- Practice prompt source rewrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-20 19:38:08 -04:00
1 parent 185a1b561a
commit e029c81274
8 files changed
+383 -116

No files matched your search

+83 -16
View File
@@ -122,6 +122,40 @@ pub fn seed_messages_for_persona(system_prompt: &str) -> Vec<ChatMessage> {
vec![ChatMessage::system(system_prompt.to_string())]
}
/// The persona id a practice conversation is stored under. Practice mode
/// plays the SMS contact, not a persona, so the conversation belongs to no
/// persona row: deleting or editing personas can't hide or break it.
pub const PRACTICE_PERSONA_ID: &str = "";
/// Name shown for a practice conversation where a persona name would go.
pub const PRACTICE_PERSONA_NAME: &str = "Practice";
/// Whether a stored conversation is a practice one (plays the contact).
fn is_practice(row: &crate::database::persona_dao::PersonaChatRow) -> bool {
row.anchor
.as_ref()
.is_some_and(|a| a.mode == crate::database::AnchorMode::AsContact)
}
/// The stand-in persona a practice turn runs with. Its prompt is never used
/// (`anchored_system_prompt` ignores the persona in practice mode) and it
/// can't correct facts — practice is offered no knowledge tools at all.
fn practice_persona(user_id: i32) -> crate::database::models::Persona {
crate::database::models::Persona {
id: 0,
user_id,
persona_id: PRACTICE_PERSONA_ID.to_string(),
name: PRACTICE_PERSONA_NAME.to_string(),
system_prompt: String::new(),
is_built_in: false,
include_all_memories: false,
created_at: 0,
updated_at: 0,
reviewed_only_facts: false,
allow_agent_corrections: false,
}
}
/// Parse a persisted transcript into the conversation tree.
///
/// Two on-disk formats are accepted, mirroring the file chat's reader:
@@ -602,8 +636,13 @@ impl PersonaChatSession {
message_id: i64,
mode: crate::database::AnchorMode,
) -> Result<String, PersonaChatError> {
let persona_id = persona_id.trim();
{
// Practice plays the contact; any persona the client sent is ignored.
let persona_id = if mode == crate::database::AnchorMode::AsContact {
PRACTICE_PERSONA_ID
} else {
persona_id.trim()
};
if mode != crate::database::AnchorMode::AsContact {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
let persona = dao
.get_persona(user_id, persona_id)
@@ -740,10 +779,13 @@ impl PersonaChatSession {
.get_persona_chat(user_id, &conversation_id)
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
.ok_or_else(|| PersonaChatError::UnknownConversation(conversation_id.clone()))?;
let persona = dao
.get_persona(user_id, &row.persona_id)
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
.ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))?;
let persona = if is_practice(&row) {
practice_persona(user_id)
} else {
dao.get_persona(user_id, &row.persona_id)
.map_err(|e| PersonaChatError::Db(anyhow!("{e:?}")))?
.ok_or_else(|| PersonaChatError::UnknownPersona(row.persona_id.clone()))?
};
(persona, row.persona_id)
};
@@ -1004,6 +1046,8 @@ impl PersonaChatSession {
"",
user_id,
&persona.persona_id,
// Thread tools only ever read this chat's own thread.
row.anchor.as_ref().map(|a| a.contact_id),
max_iterations,
&entry,
&mut fitter,
@@ -1119,9 +1163,11 @@ impl PersonaChatSession {
) -> Result<Vec<PersonaConversationSummary>> {
let (rows, personas) = {
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
let rows = dao
.list_persona_chats(user_id)
.map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?;
let rows = match contact_id {
Some(cid) => dao.list_persona_chats_for_contact(user_id, cid),
None => dao.list_persona_chats(user_id),
}
.map_err(|e| anyhow!("failed to list persona chats: {e:?}"))?;
let personas = dao
.list_personas(user_id)
.map_err(|e| anyhow!("failed to list personas: {e:?}"))?;
@@ -1134,13 +1180,13 @@ impl PersonaChatSession {
let mut out = Vec::with_capacity(rows.len());
for entry in rows {
if let Some(cid) = contact_id
&& entry.anchor.as_ref().map(|a| a.contact_id) != Some(cid)
{
continue;
}
let Some(persona_name) = names.get(&entry.persona_id).cloned() else {
continue;
let persona_name = if is_practice(&entry) {
PRACTICE_PERSONA_NAME.to_string()
} else {
match names.get(&entry.persona_id) {
Some(name) => name.clone(),
None => continue,
}
};
// A single unreadable transcript shouldn't take the whole list
// down with it — skip the row and keep going.
@@ -1372,6 +1418,9 @@ pub struct PersonaChatHistoryQuery {
/// Body for POST /persona_chat/conversations — start a new conversation.
#[derive(Debug, Deserialize)]
pub struct PersonaChatCreateRequest {
/// Required except for a practice (`as_contact`) anchor, which plays
/// the contact rather than a persona.
#[serde(default)]
pub persona_id: String,
/// Anchor the conversation on a message in an SMS thread ("Continue
/// with persona"). Omitted for an open chat.
@@ -2038,6 +2087,24 @@ mod tests {
assert!(tracker.claim("conv-a", "t7").is_ok());
}
#[test]
fn practice_create_request_needs_no_persona() {
let req: PersonaChatCreateRequest = serde_json::from_str(
r#"{"anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#,
)
.unwrap();
assert_eq!(req.persona_id, "");
}
#[test]
fn practice_persona_is_inert() {
let p = practice_persona(7);
assert_eq!(p.persona_id, PRACTICE_PERSONA_ID);
assert_eq!(p.user_id, 7);
assert!(p.system_prompt.is_empty());
assert!(!p.allow_agent_corrections);
}
#[test]
fn create_request_anchor_mode_defaults_to_about() {
use crate::database::AnchorMode;