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

+48
View File
@@ -47,6 +47,37 @@ pub struct SmsAnchor {
/// Unix seconds of the anchor message.
pub timestamp: i64,
pub context: String,
pub mode: AnchorMode,
}
/// How an anchored conversation relates to its SMS thread.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorMode {
/// A persona talks with the user about the thread.
#[default]
About,
/// The model plays the contact so the user can practise the
/// conversation.
AsContact,
}
impl AnchorMode {
pub fn as_str(self) -> &'static str {
match self {
AnchorMode::About => "about",
AnchorMode::AsContact => "as_contact",
}
}
/// Stored value → mode. NULL and anything unrecognised read as `About`,
/// the behaviour rows had before the column existed.
pub fn from_column(value: Option<&str>) -> Self {
match value {
Some("as_contact") => AnchorMode::AsContact,
_ => AnchorMode::About,
}
}
}
/// The column tuple every persona-chat read selects. Shared by the
@@ -65,6 +96,7 @@ type PersonaChatColumns = (
Option<i64>,
Option<i64>,
Option<String>,
Option<String>,
);
macro_rules! persona_chat_select {
@@ -82,6 +114,7 @@ macro_rules! persona_chat_select {
anchor_message_id,
anchor_ts,
anchor_context,
anchor_mode,
)
};
}
@@ -101,6 +134,7 @@ fn persona_chat_row(
anchor_message_id,
anchor_ts,
anchor_context,
anchor_mode,
): PersonaChatColumns,
) -> PersonaChatRow {
// An anchor needs at least the thread and the message; the rest has a
@@ -112,6 +146,7 @@ fn persona_chat_row(
message_id,
timestamp: anchor_ts.unwrap_or(0),
context: anchor_context.unwrap_or_default(),
mode: AnchorMode::from_column(anchor_mode.as_deref()),
}),
_ => None,
};
@@ -545,6 +580,7 @@ impl PersonaDao for SqlitePersonaDao {
anchor_message_id.eq(Some(anchor.message_id)),
anchor_ts.eq(Some(anchor.timestamp)),
anchor_context.eq(Some(&anchor.context)),
anchor_mode.eq(Some(anchor.mode.as_str())),
))
.execute(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
@@ -876,6 +912,7 @@ mod tests {
message_id: 9001,
timestamp: 1_700_000_000,
context: "[2023-11-14 22:13] Sam: see you there".to_string(),
mode: AnchorMode::AsContact,
};
let anchored = dao
.create_anchored_persona_chat(uid, "journal", 100, &anchor)
@@ -895,6 +932,17 @@ mod tests {
);
}
#[test]
fn anchor_mode_reads_null_and_unknown_as_about() {
assert_eq!(AnchorMode::from_column(None), AnchorMode::About);
assert_eq!(AnchorMode::from_column(Some("weird")), AnchorMode::About);
assert_eq!(AnchorMode::from_column(Some("about")), AnchorMode::About);
assert_eq!(
AnchorMode::from_column(Some(AnchorMode::AsContact.as_str())),
AnchorMode::AsContact
);
}
#[test]
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
let (mut dao, uid) = dao_with_user("p8");