huskies: merge 789
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
//! 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<String>,
|
||||
}
|
||||
|
||||
/// 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<Message>,
|
||||
},
|
||||
/// 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<UpcomingStory>,
|
||||
current: Vec<UpcomingStory>,
|
||||
qa: Vec<UpcomingStory>,
|
||||
merge: Vec<UpcomingStory>,
|
||||
done: Vec<UpcomingStory>,
|
||||
},
|
||||
/// `.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<WizardStepInfo>,
|
||||
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\" & <tagged>".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\" & <tagged>");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user