feat(persona-chat): practice mode — play the contact

An anchored conversation now has a mode. 'about' is the existing behaviour (a
persona talks with the user about the thread). 'as_contact' has the model play
the contact so the user can rehearse the conversation: its own prompt (stay in
the contact's voice, don't invent facts, coach on 'out of character'), no
persona prompt, and only the thread tools — no memory, library, calendar or
other-thread access, and no knowledge writes.

The shared persona/file-chat loop now refuses tool calls that weren't in the
turn's catalog, so the gates hold even when a model names a tool it wasn't
offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-17 18:39:27 -04:00
1 parent 4ac574d4d2
commit 3f1bf42f1c
8 files changed
+284 -29

No files matched your search

+36
View File
@@ -242,6 +242,9 @@ pub struct PersonaChatAnchorView {
pub timestamp: i64,
/// The anchor message as "Speaker: text", recovered from the snapshot.
pub preview: String,
/// `about` (a persona discusses the thread) or `as_contact` (the model
/// plays the contact for practice).
pub mode: crate::database::AnchorMode,
}
impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView {
@@ -252,6 +255,7 @@ impl From<&crate::database::SmsAnchor> for PersonaChatAnchorView {
message_id: a.message_id,
timestamp: a.timestamp,
preview: crate::ai::sms_anchor::anchor_preview(&a.context),
mode: a.mode,
}
}
}
@@ -596,6 +600,7 @@ impl PersonaChatSession {
persona_id: &str,
contact_id: i64,
message_id: i64,
mode: crate::database::AnchorMode,
) -> Result<String, PersonaChatError> {
let persona_id = persona_id.trim();
{
@@ -630,6 +635,7 @@ impl PersonaChatSession {
message_id,
timestamp: window.messages[window.anchor_index].timestamp,
context,
mode,
};
let mut dao = self.persona_dao.lock().expect("persona_dao poisoned");
@@ -935,6 +941,12 @@ impl PersonaChatSession {
);
gate_opts.sms_thread_tools = row.anchor.is_some();
let tools = InsightGenerator::build_tool_definitions(gate_opts);
// Practice mode narrows the catalog to the thread itself — no
// memory, library or calendar access for a simulated contact.
let tools = match &row.anchor {
Some(anchor) => crate::ai::sms_anchor::tools_for_mode(tools, anchor.mode),
None => tools,
};
// Append the new user turn + apply the per-turn system-prompt
// override. Per-persona overrides are NOT persisted (the persona
@@ -1368,6 +1380,9 @@ pub struct PersonaChatCreateRequest {
pub struct PersonaChatAnchorRequest {
pub contact_id: i64,
pub message_id: i64,
/// `about` (default) or `as_contact`.
#[serde(default)]
pub mode: crate::database::AnchorMode,
}
/// Query params for GET /persona_chat/conversations.
@@ -1489,6 +1504,7 @@ pub async fn persona_chat_create_conversation_handler(
&request.persona_id,
anchor.contact_id,
anchor.message_id,
anchor.mode,
)
.await
}
@@ -2018,6 +2034,24 @@ mod tests {
assert!(tracker.claim("conv-a", "t7").is_ok());
}
#[test]
fn create_request_anchor_mode_defaults_to_about() {
use crate::database::AnchorMode;
let req: PersonaChatCreateRequest = serde_json::from_str(
r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812}}"#,
)
.unwrap();
assert_eq!(req.anchor.unwrap().mode, AnchorMode::About);
let req: PersonaChatCreateRequest = serde_json::from_str(
r#"{"persona_id":"journal","anchor":{"contact_id":42,"message_id":812,"mode":"as_contact"}}"#,
)
.unwrap();
assert_eq!(req.anchor.unwrap().mode, AnchorMode::AsContact);
let req: PersonaChatCreateRequest =
serde_json::from_str(r#"{"persona_id":"journal"}"#).unwrap();
assert!(req.anchor.is_none());
}
#[test]
fn persona_chat_turn_request_deserializes_snake_case_wire_body() {
// Pins the mobile-client contract: snake_case keys, same as the
@@ -2273,12 +2307,14 @@ mod tests {
message_id: 812,
timestamp: 1_700_000_000,
preview: "Sam: see you at 6".to_string(),
mode: crate::database::AnchorMode::AsContact,
}),
};
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(json["anchor"]["contact_id"], 42);
assert_eq!(json["anchor"]["message_id"], 812);
assert_eq!(json["anchor"]["preview"], "Sam: see you at 6");
assert_eq!(json["anchor"]["mode"], "as_contact");
assert_eq!(json["conversation_id"], "conv-1");
assert_eq!(json["title"], "June recap");
assert_eq!(json["persona_id"], "journal");