fix: send persona_chat wire shapes in snake_case like file chat

The mobile client dispatches POST /persona_chat/turn with snake_case
keys (persona_id, user_message, num_ctx, ...) per the file-chat
convention, but the persona request/response structs carried camelCase
serde renames (personaId, userMessage, ...), so every dispatch 400'd
with 'missing field personaId'. Drop the renames from
PersonaChatTurnRequest, PersonaChatHistoryView, RenderedPersonaMessage,
and PersonaChatResetRequest — the persona wire shapes now match the
rest of the API (history query, 202 turn_id, SSE skip_before) — and
pin the contract with serialization round-trip tests.
This commit is contained in:
Cameron Cordes
2026-08-24 21:34:46 -04:00
parent 3b9d985025
commit 883e2a0e1b
+68 -17
View File
@@ -158,38 +158,36 @@ fn is_seed_greeting(content: &str) -> bool {
} }
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
// Wire shapes — camelCase out the door. // Wire shapes — snake_case, matching the file-chat convention.
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct PersonaChatTurnRequest { pub struct PersonaChatTurnRequest {
/// Active persona id. Must match a row in the user's persona store /// Active persona id. Must match a row in the user's persona store
/// (built-ins seeded by migration + customs via /personas). /// (built-ins seeded by migration + customs via /personas).
#[serde(rename = "personaId")]
pub persona_id: String, pub persona_id: String,
/// Free-text user message. Trimmed before validation; empty input is a 400. /// Free-text user message. Trimmed before validation; empty input is a 400.
#[serde(rename = "userMessage")]
pub user_message: String, pub user_message: String,
#[serde(default)] #[serde(default)]
pub model: Option<String>, pub model: Option<String>,
#[serde(default)] #[serde(default)]
pub backend: Option<String>, pub backend: Option<String>,
#[serde(default, rename = "numCtx")] #[serde(default)]
pub num_ctx: Option<i32>, pub num_ctx: Option<i32>,
#[serde(default)] #[serde(default)]
pub temperature: Option<f32>, pub temperature: Option<f32>,
#[serde(default, rename = "topP")] #[serde(default)]
pub top_p: Option<f32>, pub top_p: Option<f32>,
#[serde(default, rename = "topK")] #[serde(default)]
pub top_k: Option<i32>, pub top_k: Option<i32>,
#[serde(default, rename = "minP")] #[serde(default)]
pub min_p: Option<f32>, pub min_p: Option<f32>,
#[serde(default, rename = "enableThinking")] #[serde(default)]
pub enable_thinking: Option<bool>, pub enable_thinking: Option<bool>,
/// Per-turn system-prompt override. Appended to the persona's prompt /// Per-turn system-prompt override. Appended to the persona's prompt
/// for this turn only; not persisted (persona voice survives a /// for this turn only; not persisted (persona voice survives a
/// voice-flipped turn). Empty / whitespace = no override. /// voice-flipped turn). Empty / whitespace = no override.
#[serde(default, rename = "systemPrompt")] #[serde(default)]
pub system_prompt: Option<String>, pub system_prompt: Option<String>,
#[serde(default)] #[serde(default)]
pub library: Option<String>, pub library: Option<String>,
@@ -200,16 +198,11 @@ pub struct PersonaChatHistoryView {
/// Rendered transcript — same message shape the file chat's history /// Rendered transcript — same message shape the file chat's history
/// endpoint ships, so the client's renderer is shared. /// endpoint ships, so the client's renderer is shared.
pub messages: Vec<RenderedPersonaMessage>, pub messages: Vec<RenderedPersonaMessage>,
#[serde(rename = "turnCount")]
pub turn_count: u32, pub turn_count: u32,
#[serde(rename = "modelVersion")]
pub model_version: String, pub model_version: String,
pub backend: String, pub backend: String,
#[serde(rename = "activeLeafId")]
pub active_leaf_id: i64, pub active_leaf_id: i64,
#[serde(rename = "viewingBranchId")]
pub viewing_branch_id: i64, pub viewing_branch_id: i64,
#[serde(rename = "forkInfo")]
pub fork_info: Vec<serde_json::Value>, pub fork_info: Vec<serde_json::Value>,
} }
@@ -220,9 +213,8 @@ pub struct PersonaChatHistoryView {
pub struct RenderedPersonaMessage { pub struct RenderedPersonaMessage {
pub role: String, pub role: String,
pub content: String, pub content: String,
#[serde(rename = "isInitial")]
pub is_initial: bool, pub is_initial: bool,
#[serde(rename = "tools", skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<crate::ai::handlers::HistoryToolInvocation>, pub tools: Vec<crate::ai::handlers::HistoryToolInvocation>,
} }
@@ -711,7 +703,6 @@ pub struct PersonaChatHistoryQuery {
/// one persona. /// one persona.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct PersonaChatResetRequest { pub struct PersonaChatResetRequest {
#[serde(rename = "personaId")]
pub persona_id: String, pub persona_id: String,
} }
@@ -1068,4 +1059,64 @@ mod tests {
// Guard dropped → slot free again. // Guard dropped → slot free again.
assert!(tracker.claim(1, "default", "t7").is_ok()); assert!(tracker.claim(1, "default", "t7").is_ok());
} }
#[test]
fn persona_chat_turn_request_deserializes_snake_case_wire_body() {
// Pins the mobile-client contract: snake_case keys, same as the
// file-chat request shapes. A camelCase regression here 400s the
// client's dispatch with "missing field persona_id".
let body = r#"{
"persona_id": "journal",
"user_message": "hello",
"num_ctx": 4096,
"top_p": 0.9,
"top_k": 40,
"min_p": 0.05,
"enable_thinking": true,
"system_prompt": "be brief",
"library": "main"
}"#;
let req: PersonaChatTurnRequest = serde_json::from_str(body).unwrap();
assert_eq!(req.persona_id, "journal");
assert_eq!(req.user_message, "hello");
assert_eq!(req.num_ctx, Some(4096));
assert_eq!(req.top_p, Some(0.9));
assert_eq!(req.top_k, Some(40));
assert_eq!(req.min_p, Some(0.05));
assert_eq!(req.enable_thinking, Some(true));
assert_eq!(req.system_prompt.as_deref(), Some("be brief"));
assert_eq!(req.library.as_deref(), Some("main"));
}
#[test]
fn persona_chat_history_view_serializes_snake_case_wire_shape() {
let view = PersonaChatHistoryView {
messages: vec![RenderedPersonaMessage {
role: "user".to_string(),
content: "hi".to_string(),
is_initial: true,
tools: Vec::new(),
}],
turn_count: 1,
model_version: "x".to_string(),
backend: "local".to_string(),
active_leaf_id: 0,
viewing_branch_id: 0,
fork_info: Vec::new(),
};
let json = serde_json::to_value(&view).unwrap();
// The client's shared ChatHistoryView reads snake_case keys.
assert_eq!(json["turn_count"], 1);
assert_eq!(json["active_leaf_id"], 0);
assert_eq!(json["viewing_branch_id"], 0);
assert_eq!(json["messages"][0]["is_initial"], true);
assert!(json["messages"][0].get("tools").is_none());
}
#[test]
fn persona_chat_reset_request_deserializes_snake_case_wire_body() {
let req: PersonaChatResetRequest =
serde_json::from_str(r#"{"persona_id": "journal"}"#).unwrap();
assert_eq!(req.persona_id, "journal");
}
} }