diff --git a/src/ai/persona_chat.rs b/src/ai/persona_chat.rs index 8fbf071..dee054d 100644 --- a/src/ai/persona_chat.rs +++ b/src/ai/persona_chat.rs @@ -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)] pub struct PersonaChatTurnRequest { /// Active persona id. Must match a row in the user's persona store /// (built-ins seeded by migration + customs via /personas). - #[serde(rename = "personaId")] pub persona_id: String, /// Free-text user message. Trimmed before validation; empty input is a 400. - #[serde(rename = "userMessage")] pub user_message: String, #[serde(default)] pub model: Option, #[serde(default)] pub backend: Option, - #[serde(default, rename = "numCtx")] + #[serde(default)] pub num_ctx: Option, #[serde(default)] pub temperature: Option, - #[serde(default, rename = "topP")] + #[serde(default)] pub top_p: Option, - #[serde(default, rename = "topK")] + #[serde(default)] pub top_k: Option, - #[serde(default, rename = "minP")] + #[serde(default)] pub min_p: Option, - #[serde(default, rename = "enableThinking")] + #[serde(default)] pub enable_thinking: Option, /// Per-turn system-prompt override. Appended to the persona's prompt /// for this turn only; not persisted (persona voice survives a /// voice-flipped turn). Empty / whitespace = no override. - #[serde(default, rename = "systemPrompt")] + #[serde(default)] pub system_prompt: Option, #[serde(default)] pub library: Option, @@ -200,16 +198,11 @@ pub struct PersonaChatHistoryView { /// Rendered transcript — same message shape the file chat's history /// endpoint ships, so the client's renderer is shared. pub messages: Vec, - #[serde(rename = "turnCount")] pub turn_count: u32, - #[serde(rename = "modelVersion")] pub model_version: String, pub backend: String, - #[serde(rename = "activeLeafId")] pub active_leaf_id: i64, - #[serde(rename = "viewingBranchId")] pub viewing_branch_id: i64, - #[serde(rename = "forkInfo")] pub fork_info: Vec, } @@ -220,9 +213,8 @@ pub struct PersonaChatHistoryView { pub struct RenderedPersonaMessage { pub role: String, pub content: String, - #[serde(rename = "isInitial")] pub is_initial: bool, - #[serde(rename = "tools", skip_serializing_if = "Vec::is_empty")] + #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, } @@ -711,7 +703,6 @@ pub struct PersonaChatHistoryQuery { /// one persona. #[derive(Debug, Deserialize)] pub struct PersonaChatResetRequest { - #[serde(rename = "personaId")] pub persona_id: String, } @@ -1068,4 +1059,64 @@ mod tests { // Guard dropped → slot free again. 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"); + } } \ No newline at end of file