use serde::{Deserialize, Serialize};
use crate::bash_background::BgTaskStatus;
pub type StatusPayload = serde_json::Value;
pub const ERROR_PERMISSION_REQUIRED: &str = "permission_required";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProgressKind {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProgressFrame {
#[serde(rename = "type")]
pub frame_type: &'static str,
pub request_id: String,
pub kind: ProgressKind,
pub chunk: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PermissionAskFrame {
#[serde(rename = "type")]
pub frame_type: &'static str,
pub request_id: String,
pub asks: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct BashCompletedFrame {
#[serde(rename = "type")]
pub frame_type: &'static str,
pub task_id: String,
pub session_id: String,
pub status: BgTaskStatus,
pub exit_code: Option<i32>,
pub command: String,
#[serde(default)]
pub output_preview: String,
#[serde(default)]
pub output_truncated: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BashLongRunningFrame {
#[serde(rename = "type")]
pub frame_type: &'static str,
pub task_id: String,
pub session_id: String,
pub command: String,
pub elapsed_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConfigureWarningsFrame {
#[serde(rename = "type")]
pub frame_type: &'static str,
#[serde(default)]
pub session_id: Option<String>,
pub project_root: String,
pub source_file_count: usize,
pub source_file_count_exceeds_max: bool,
pub max_callgraph_files: usize,
pub warnings: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatusChangedFrame {
#[serde(rename = "type")]
pub frame_type: &'static str,
#[serde(default)]
pub session_id: Option<String>,
pub snapshot: StatusPayload,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum PushFrame {
Progress(ProgressFrame),
BashCompleted(BashCompletedFrame),
BashLongRunning(BashLongRunningFrame),
ConfigureWarnings(ConfigureWarningsFrame),
StatusChanged(StatusChangedFrame),
}
impl PermissionAskFrame {
pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
Self {
frame_type: "permission_ask",
request_id: request_id.into(),
asks,
}
}
}
impl ProgressFrame {
pub fn new(
request_id: impl Into<String>,
kind: ProgressKind,
chunk: impl Into<String>,
) -> Self {
Self {
frame_type: "progress",
request_id: request_id.into(),
kind,
chunk: chunk.into(),
}
}
}
impl ConfigureWarningsFrame {
pub fn new(
project_root: impl Into<String>,
source_file_count: usize,
source_file_count_exceeds_max: bool,
max_callgraph_files: usize,
warnings: Vec<serde_json::Value>,
) -> Self {
Self::new_with_session_id(
None,
project_root,
source_file_count,
source_file_count_exceeds_max,
max_callgraph_files,
warnings,
)
}
pub fn new_with_session_id(
session_id: Option<String>,
project_root: impl Into<String>,
source_file_count: usize,
source_file_count_exceeds_max: bool,
max_callgraph_files: usize,
warnings: Vec<serde_json::Value>,
) -> Self {
Self {
frame_type: "configure_warnings",
session_id,
project_root: project_root.into(),
source_file_count,
source_file_count_exceeds_max,
max_callgraph_files,
warnings,
}
}
}
impl StatusChangedFrame {
pub fn new(session_id: Option<String>, snapshot: StatusPayload) -> Self {
Self {
frame_type: "status_changed",
session_id,
snapshot,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize)]
struct ConfigureWarningsFrameRoundTrip {
#[serde(rename = "type")]
frame_type: String,
session_id: Option<String>,
project_root: String,
source_file_count: usize,
max_callgraph_files: usize,
warnings: Vec<serde_json::Value>,
}
#[test]
fn configure_warnings_frame_serializes_null_session_id_by_default() {
let frame = ConfigureWarningsFrame::new(
"/repo",
42,
false,
5_000,
vec![json!({
"kind": "formatter_not_installed",
"tool": "biome",
"hint": "Install biome."
})],
);
let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
let decoded: ConfigureWarningsFrameRoundTrip =
serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
assert_eq!(decoded.session_id, None);
}
#[test]
fn configure_warnings_frame_serializes_session_id() {
let frame = ConfigureWarningsFrame::new_with_session_id(
Some("session-1".to_string()),
"/repo",
42,
false,
5_000,
vec![json!({
"kind": "formatter_not_installed",
"tool": "biome",
"hint": "Install biome."
})],
);
let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
let decoded: ConfigureWarningsFrameRoundTrip =
serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
assert_eq!(decoded.frame_type, "configure_warnings");
assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
assert_eq!(decoded.project_root, "/repo");
assert_eq!(decoded.source_file_count, 42);
assert_eq!(decoded.max_callgraph_files, 5_000);
assert_eq!(decoded.warnings[0]["tool"], "biome");
}
#[test]
fn status_changed_frame_serializes_correctly() {
let frame = StatusChangedFrame::new(
None,
json!({
"version": "0.24.0",
"project_root": "/repo",
"cache_role": "main",
"canonical_root": "/repo",
"search_index": { "status": "ready" },
"semantic_index": { "status": "disabled" },
}),
);
let json = serde_json::to_value(PushFrame::StatusChanged(frame)).unwrap();
assert_eq!(json["type"], "status_changed");
assert!(json["session_id"].is_null());
assert_eq!(json["snapshot"]["cache_role"], "main");
assert_eq!(json["snapshot"]["project_root"], "/repo");
}
}
impl BashCompletedFrame {
pub fn new(
task_id: impl Into<String>,
session_id: impl Into<String>,
status: BgTaskStatus,
exit_code: Option<i32>,
command: impl Into<String>,
output_preview: impl Into<String>,
output_truncated: bool,
) -> Self {
Self {
frame_type: "bash_completed",
task_id: task_id.into(),
session_id: session_id.into(),
status,
exit_code,
command: command.into(),
output_preview: output_preview.into(),
output_truncated,
}
}
}
impl BashLongRunningFrame {
pub fn new(
task_id: impl Into<String>,
session_id: impl Into<String>,
command: impl Into<String>,
elapsed_ms: u64,
) -> Self {
Self {
frame_type: "bash_long_running",
task_id: task_id.into(),
session_id: session_id.into(),
command: command.into(),
elapsed_ms,
}
}
}
pub const DEFAULT_SESSION_ID: &str = "__default__";
#[derive(Debug, Deserialize)]
pub struct RawRequest {
pub id: String,
#[serde(alias = "method")]
pub command: String,
#[serde(default)]
pub lsp_hints: Option<serde_json::Value>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(flatten)]
pub params: serde_json::Value,
}
impl RawRequest {
pub fn session(&self) -> &str {
self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
}
}
#[derive(Debug, Serialize)]
pub struct Response {
pub id: String,
pub success: bool,
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Debug, Deserialize)]
pub struct EchoParams {
pub message: String,
}
impl Response {
pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
Response {
id: id.into(),
success: true,
data,
}
}
pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
Response {
id: id.into(),
success: false,
data: serde_json::json!({
"code": code,
"message": message.into(),
}),
}
}
pub fn error_with_data(
id: impl Into<String>,
code: &str,
message: impl Into<String>,
extra: serde_json::Value,
) -> Self {
let mut data = serde_json::json!({
"code": code,
"message": message.into(),
});
if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
for (k, v) in ext {
base.insert(k.clone(), v.clone());
}
}
Response {
id: id.into(),
success: false,
data,
}
}
}