//! WebSocket response messages sent by the server. use crate::http::workflow::UpcomingStory; use crate::llm::types::Message; use crate::service::status::StatusEvent; use serde::Serialize; /// Serialisable summary of a single wizard step for WebSocket broadcast. #[derive(Serialize, Clone, Debug, PartialEq)] pub struct WizardStepInfo { pub step: String, pub label: String, pub status: String, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, } /// WebSocket response messages sent by the server. /// /// - `token` streams partial model output. /// - `update` pushes the updated message history. /// - `error` reports a request or processing failure. /// - `work_item_changed` notifies that a `.huskies/work/` file changed. /// - `agent_config_changed` notifies that `.huskies/project.toml` was modified. #[derive(Serialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WsResponse { Token { content: String, }, Update { messages: Vec, }, /// Session ID for Claude Code conversation resumption. SessionId { session_id: String, }, Error { message: String, }, /// Filesystem watcher notification: a work-pipeline file was created or /// modified and auto-committed. The frontend can use this to refresh its /// story/bug list without polling. WorkItemChanged { stage: String, item_id: String, action: String, commit_msg: String, }, /// Full pipeline state pushed on connect and after every work-item watcher event. PipelineState { backlog: Vec, current: Vec, qa: Vec, merge: Vec, done: Vec, }, /// `.huskies/project.toml` was modified; the frontend should re-fetch the /// agent roster. Does NOT trigger a pipeline state refresh. AgentConfigChanged, /// An agent's state changed (started, stopped, completed, etc.). /// Triggers a pipeline state refresh and tells the frontend to re-fetch /// the agent list. AgentStateChanged, /// Claude Code is requesting user approval before executing a tool. PermissionRequest { request_id: String, tool_name: String, tool_input: serde_json::Value, }, /// The agent started assembling a tool call; shows live status in the UI. ToolActivity { tool_name: String, }, /// Real-time progress from the server startup reconciliation pass. /// `status` is one of: "checking", "gates_running", "advanced", "skipped", /// "failed", "done". `story_id` is empty for the overall "done" event. ReconciliationProgress { story_id: String, status: String, message: String, }, /// Heartbeat response to a client `Ping`. Lets the client confirm the /// connection is alive and cancel any stale-connection timeout. Pong, /// Streaming thinking token from an extended-thinking block. /// Sent separately from `Token` so the frontend can render them in /// a constrained, scrollable ThinkingBlock rather than inline. ThinkingToken { content: String, }, /// Sent on connect when the project's spec files still contain scaffold /// placeholder content and the user needs to go through onboarding. OnboardingStatus { needs_onboarding: bool, }, /// Sent on connect when a setup wizard is active. Contains the full /// wizard state so the frontend can render the step-by-step UI. WizardState { steps: Vec, current_step_index: usize, completed: bool, }, /// Streaming token from a `/btw` side question response. SideQuestionToken { content: String, }, /// Final signal that the `/btw` side question has been fully answered. SideQuestionDone { response: String, }, /// A single server log entry. Sent in bulk on connect (recent history), /// then streamed live as new entries arrive. LogEntry { timestamp: String, level: String, message: String, }, /// A structured pipeline status event forwarded from the status broadcaster. /// /// The structured [`StatusEvent`] fields are preserved on the wire so /// frontend consumers can do per-type presentation without parsing strings. /// This frame intentionally does NOT call `format_status_event` — that /// formatter is reserved for chat transports (story 644). StatusUpdate { event: StatusEvent, }, } #[cfg(test)] mod tests { use super::*; use crate::http::workflow::UpcomingStory; #[test] fn serialize_token_response() { let resp = WsResponse::Token { content: "hello world".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "token"); assert_eq!(json["content"], "hello world"); } #[test] fn serialize_update_response() { let msg = Message { role: crate::llm::types::Role::Assistant, content: "response".to_string(), tool_calls: None, tool_call_id: None, }; let resp = WsResponse::Update { messages: vec![msg], }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "update"); assert_eq!(json["messages"].as_array().unwrap().len(), 1); assert_eq!(json["messages"][0]["content"], "response"); } #[test] fn serialize_session_id_response() { let resp = WsResponse::SessionId { session_id: "sess-abc".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "session_id"); assert_eq!(json["session_id"], "sess-abc"); } #[test] fn serialize_error_response() { let resp = WsResponse::Error { message: "something broke".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "error"); assert_eq!(json["message"], "something broke"); } #[test] fn serialize_work_item_changed_response() { let resp = WsResponse::WorkItemChanged { stage: "2_current".to_string(), item_id: "42_story_foo".to_string(), action: "start".to_string(), commit_msg: "huskies: start 42_story_foo".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "work_item_changed"); assert_eq!(json["stage"], "2_current"); assert_eq!(json["item_id"], "42_story_foo"); assert_eq!(json["action"], "start"); assert_eq!(json["commit_msg"], "huskies: start 42_story_foo"); } #[test] fn serialize_pipeline_state_response() { let story = UpcomingStory { story_id: "10_story_test".to_string(), name: Some("Test".to_string()), error: None, merge_failure: None, agent: None, review_hold: None, qa: None, retry_count: None, blocked: None, depends_on: None, }; let resp = WsResponse::PipelineState { backlog: vec![story], current: vec![], qa: vec![], merge: vec![], done: vec![], }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "pipeline_state"); assert_eq!(json["backlog"].as_array().unwrap().len(), 1); assert_eq!(json["backlog"][0]["story_id"], "10_story_test"); assert!(json["current"].as_array().unwrap().is_empty()); assert!(json["done"].as_array().unwrap().is_empty()); } #[test] fn serialize_agent_config_changed_response() { let resp = WsResponse::AgentConfigChanged; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "agent_config_changed"); } #[test] fn serialize_pong_response() { let resp = WsResponse::Pong; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "pong"); } #[test] fn serialize_thinking_token_response() { let resp = WsResponse::ThinkingToken { content: "I need to think about this...".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "thinking_token"); assert_eq!(json["content"], "I need to think about this..."); } #[test] fn serialize_onboarding_status_true() { let resp = WsResponse::OnboardingStatus { needs_onboarding: true, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "onboarding_status"); assert_eq!(json["needs_onboarding"], true); } #[test] fn serialize_onboarding_status_false() { let resp = WsResponse::OnboardingStatus { needs_onboarding: false, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "onboarding_status"); assert_eq!(json["needs_onboarding"], false); } #[test] fn serialize_permission_request_response() { let resp = WsResponse::PermissionRequest { request_id: "perm-1".to_string(), tool_name: "Bash".to_string(), tool_input: serde_json::json!({"command": "ls"}), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "permission_request"); assert_eq!(json["request_id"], "perm-1"); assert_eq!(json["tool_name"], "Bash"); assert_eq!(json["tool_input"]["command"], "ls"); } #[test] fn serialize_tool_activity_response() { let resp = WsResponse::ToolActivity { tool_name: "Read".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "tool_activity"); assert_eq!(json["tool_name"], "Read"); } #[test] fn serialize_reconciliation_progress_response() { let resp = WsResponse::ReconciliationProgress { story_id: "50_story_x".to_string(), status: "gates_running".to_string(), message: "Running clippy...".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "reconciliation_progress"); assert_eq!(json["story_id"], "50_story_x"); assert_eq!(json["status"], "gates_running"); assert_eq!(json["message"], "Running clippy..."); } #[test] fn serialize_wizard_state_response() { let resp = WsResponse::WizardState { steps: vec![WizardStepInfo { step: "scaffold".to_string(), label: "Scaffold directory structure".to_string(), status: "pending".to_string(), content: None, }], current_step_index: 0, completed: false, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "wizard_state"); assert_eq!(json["steps"][0]["step"], "scaffold"); assert_eq!(json["current_step_index"], 0); assert_eq!(json["completed"], false); } #[test] fn serialize_side_question_token() { let resp = WsResponse::SideQuestionToken { content: "partial answer".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "side_question_token"); assert_eq!(json["content"], "partial answer"); } #[test] fn serialize_side_question_done() { let resp = WsResponse::SideQuestionDone { response: "full answer".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "side_question_done"); assert_eq!(json["response"], "full answer"); } #[test] fn serialize_log_entry() { let resp = WsResponse::LogEntry { timestamp: "2026-01-01T00:00:00Z".to_string(), level: "INFO".to_string(), message: "server started".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "log_entry"); assert_eq!(json["level"], "INFO"); assert_eq!(json["message"], "server started"); } #[test] fn ws_response_serializes_to_parseable_json_string() { let resp = WsResponse::Error { message: "test error".to_string(), }; let text = serde_json::to_string(&resp).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(parsed["type"], "error"); assert_eq!(parsed["message"], "test error"); } #[test] fn ws_response_update_with_empty_messages() { let resp = WsResponse::Update { messages: vec![] }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "update"); assert!(json["messages"].as_array().unwrap().is_empty()); } #[test] fn ws_response_token_with_empty_content() { let resp = WsResponse::Token { content: String::new(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "token"); assert_eq!(json["content"], ""); } #[test] fn ws_response_error_with_special_characters() { let resp = WsResponse::Error { message: "error: \"quoted\" & ".to_string(), }; let text = serde_json::to_string(&resp).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&text).unwrap(); assert_eq!(parsed["message"], "error: \"quoted\" & "); } #[test] fn reconciliation_done_event_has_empty_story_id() { let resp = WsResponse::ReconciliationProgress { story_id: String::new(), status: "done".to_string(), message: "Reconciliation complete".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["story_id"], ""); assert_eq!(json["status"], "done"); } }