1use serde::{Deserialize, Serialize};
2
3use crate::bash_background::BgTaskStatus;
4
5pub type StatusPayload = serde_json::Value;
7
8pub const ERROR_PERMISSION_REQUIRED: &str = "permission_required";
18
19#[derive(Debug, Clone, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum ProgressKind {
22 Stdout,
23 Stderr,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct ProgressFrame {
28 #[serde(rename = "type")]
29 pub frame_type: &'static str,
30 pub request_id: String,
31 pub kind: ProgressKind,
32 pub chunk: String,
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct PermissionAskFrame {
37 #[serde(rename = "type")]
38 pub frame_type: &'static str,
39 pub request_id: String,
40 pub asks: serde_json::Value,
41}
42
43#[derive(Debug, Clone, Serialize)]
44pub struct BashCompletedFrame {
45 #[serde(rename = "type")]
46 pub frame_type: &'static str,
47 pub task_id: String,
48 pub session_id: String,
49 pub status: BgTaskStatus,
50 pub exit_code: Option<i32>,
51 pub command: String,
52 #[serde(default)]
57 pub output_preview: String,
58 #[serde(default)]
62 pub output_truncated: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
66 pub original_tokens: Option<u32>,
67 #[serde(skip_serializing_if = "Option::is_none")]
70 pub compressed_tokens: Option<u32>,
71 #[serde(default)]
73 pub tokens_skipped: bool,
74}
75
76#[derive(Debug, Clone, Serialize)]
77pub struct BashLongRunningFrame {
78 #[serde(rename = "type")]
79 pub frame_type: &'static str,
80 pub task_id: String,
81 pub session_id: String,
82 pub command: String,
83 pub elapsed_ms: u64,
84}
85
86#[derive(Debug, Clone, Serialize)]
87pub struct BashPatternMatchFrame {
88 #[serde(rename = "type")]
89 pub frame_type: &'static str,
90 pub task_id: String,
91 pub session_id: String,
92 pub watch_id: String,
93 pub match_text: String,
94 pub match_offset: u64,
95 pub context: String,
96 pub once: bool,
97 pub reason: &'static str,
98}
99
100#[derive(Debug, Clone, Serialize)]
108pub struct ConfigureWarningsFrame {
109 #[serde(rename = "type")]
110 pub frame_type: &'static str,
111 #[serde(default)]
115 pub session_id: Option<String>,
116 pub project_root: String,
119 pub source_file_count: usize,
121 pub warnings: Vec<serde_json::Value>,
123}
124
125#[derive(Debug, Clone, Serialize)]
126pub struct StatusChangedFrame {
127 #[serde(rename = "type")]
128 pub frame_type: &'static str,
129 #[serde(default)]
130 pub session_id: Option<String>,
131 pub snapshot: StatusPayload,
132}
133
134#[derive(Debug, Clone, Serialize)]
135#[serde(untagged)]
136pub enum PushFrame {
137 Progress(ProgressFrame),
138 BashCompleted(BashCompletedFrame),
139 BashLongRunning(BashLongRunningFrame),
140 BashPatternMatch(BashPatternMatchFrame),
141 ConfigureWarnings(ConfigureWarningsFrame),
142 StatusChanged(StatusChangedFrame),
143}
144
145impl PermissionAskFrame {
146 pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
147 Self {
148 frame_type: "permission_ask",
149 request_id: request_id.into(),
150 asks,
151 }
152 }
153}
154
155impl ProgressFrame {
156 pub fn new(
157 request_id: impl Into<String>,
158 kind: ProgressKind,
159 chunk: impl Into<String>,
160 ) -> Self {
161 Self {
162 frame_type: "progress",
163 request_id: request_id.into(),
164 kind,
165 chunk: chunk.into(),
166 }
167 }
168}
169
170impl ConfigureWarningsFrame {
171 pub fn new(
172 project_root: impl Into<String>,
173 source_file_count: usize,
174 warnings: Vec<serde_json::Value>,
175 ) -> Self {
176 Self::new_with_session_id(None, project_root, source_file_count, warnings)
177 }
178
179 pub fn new_with_session_id(
180 session_id: Option<String>,
181 project_root: impl Into<String>,
182 source_file_count: usize,
183 warnings: Vec<serde_json::Value>,
184 ) -> Self {
185 Self {
186 frame_type: "configure_warnings",
187 session_id,
188 project_root: project_root.into(),
189 source_file_count,
190 warnings,
191 }
192 }
193}
194
195impl StatusChangedFrame {
196 pub fn new(session_id: Option<String>, snapshot: StatusPayload) -> Self {
197 Self {
198 frame_type: "status_changed",
199 session_id,
200 snapshot: status_push_payload(snapshot),
201 }
202 }
203}
204
205fn status_push_payload(mut snapshot: StatusPayload) -> StatusPayload {
206 if let Some(object) = snapshot.as_object_mut() {
207 object.remove("session");
208 if let Some(compression) = object
209 .get_mut("compression")
210 .and_then(serde_json::Value::as_object_mut)
211 {
212 compression.remove("session");
213 }
214 }
215 snapshot
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use serde::Deserialize;
222 use serde_json::json;
223
224 #[derive(Debug, Deserialize)]
225 struct ConfigureWarningsFrameRoundTrip {
226 #[serde(rename = "type")]
227 frame_type: String,
228 session_id: Option<String>,
229 project_root: String,
230 source_file_count: usize,
231 warnings: Vec<serde_json::Value>,
232 }
233
234 #[test]
235 fn configure_warnings_frame_serializes_null_session_id_by_default() {
236 let frame = ConfigureWarningsFrame::new(
237 "/repo",
238 42,
239 vec![json!({
240 "kind": "formatter_not_installed",
241 "tool": "biome",
242 "hint": "Install biome."
243 })],
244 );
245
246 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
247 let decoded: ConfigureWarningsFrameRoundTrip =
248 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
249
250 assert_eq!(decoded.session_id, None);
251 }
252
253 #[test]
254 fn configure_warnings_frame_serializes_session_id() {
255 let frame = ConfigureWarningsFrame::new_with_session_id(
256 Some("session-1".to_string()),
257 "/repo",
258 42,
259 vec![json!({
260 "kind": "formatter_not_installed",
261 "tool": "biome",
262 "hint": "Install biome."
263 })],
264 );
265
266 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
267 let decoded: ConfigureWarningsFrameRoundTrip =
268 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
269
270 assert_eq!(decoded.frame_type, "configure_warnings");
271 assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
272 assert_eq!(decoded.project_root, "/repo");
273 assert_eq!(decoded.source_file_count, 42);
274 assert_eq!(decoded.warnings[0]["tool"], "biome");
275 }
276
277 #[test]
278 fn status_changed_frame_serializes_correctly() {
279 let frame = StatusChangedFrame::new(
280 None,
281 json!({
282 "version": "0.24.0",
283 "project_root": "/repo",
284 "cache_role": "main",
285 "canonical_root": "/repo",
286 "search_index": { "status": "ready" },
287 "semantic_index": { "status": "disabled" },
288 }),
289 );
290
291 let json = serde_json::to_value(PushFrame::StatusChanged(frame)).unwrap();
292 assert_eq!(json["type"], "status_changed");
293 assert!(json["session_id"].is_null());
294 assert_eq!(json["snapshot"]["cache_role"], "main");
295 assert_eq!(json["snapshot"]["project_root"], "/repo");
296 }
297
298 #[test]
299 fn status_changed_frame_strips_session_scoped_push_fields() {
300 let frame = StatusChangedFrame::new(
301 None,
302 json!({
303 "version": "0.24.0",
304 "checkpoints_total": 7,
305 "session": { "id": "default", "tracked_files": 2, "checkpoints": 1 },
306 "compression": {
307 "project": { "events": 3 },
308 "session": { "events": 99 }
309 }
310 }),
311 );
312
313 assert!(frame.snapshot.get("session").is_none());
314 assert_eq!(frame.snapshot["checkpoints_total"], 7);
315 assert_eq!(frame.snapshot["compression"]["project"]["events"], 3);
316 assert!(frame.snapshot["compression"].get("session").is_none());
317 }
318}
319
320impl BashCompletedFrame {
321 pub fn new(
322 task_id: impl Into<String>,
323 session_id: impl Into<String>,
324 status: BgTaskStatus,
325 exit_code: Option<i32>,
326 command: impl Into<String>,
327 output_preview: impl Into<String>,
328 output_truncated: bool,
329 original_tokens: Option<u32>,
330 compressed_tokens: Option<u32>,
331 tokens_skipped: bool,
332 ) -> Self {
333 Self {
334 frame_type: "bash_completed",
335 task_id: task_id.into(),
336 session_id: session_id.into(),
337 status,
338 exit_code,
339 command: command.into(),
340 output_preview: output_preview.into(),
341 output_truncated,
342 original_tokens,
343 compressed_tokens,
344 tokens_skipped,
345 }
346 }
347}
348
349impl BashLongRunningFrame {
350 pub fn new(
351 task_id: impl Into<String>,
352 session_id: impl Into<String>,
353 command: impl Into<String>,
354 elapsed_ms: u64,
355 ) -> Self {
356 Self {
357 frame_type: "bash_long_running",
358 task_id: task_id.into(),
359 session_id: session_id.into(),
360 command: command.into(),
361 elapsed_ms,
362 }
363 }
364}
365
366impl BashPatternMatchFrame {
367 pub fn new(
368 task_id: impl Into<String>,
369 session_id: impl Into<String>,
370 watch_id: impl Into<String>,
371 match_text: impl Into<String>,
372 match_offset: u64,
373 context: impl Into<String>,
374 once: bool,
375 ) -> Self {
376 Self {
377 frame_type: "bash_pattern_match",
378 task_id: task_id.into(),
379 session_id: session_id.into(),
380 watch_id: watch_id.into(),
381 match_text: match_text.into(),
382 match_offset,
383 context: context.into(),
384 once,
385 reason: "pattern_match",
386 }
387 }
388
389 pub fn task_exit(
390 task_id: impl Into<String>,
391 session_id: impl Into<String>,
392 match_text: impl Into<String>,
393 context: impl Into<String>,
394 ) -> Self {
395 Self {
396 frame_type: "bash_pattern_match",
397 task_id: task_id.into(),
398 session_id: session_id.into(),
399 watch_id: "exit".to_string(),
400 match_text: match_text.into(),
401 match_offset: 0,
402 context: context.into(),
403 once: true,
404 reason: "task_exit",
405 }
406 }
407}
408
409pub const DEFAULT_SESSION_ID: &str = "__default__";
419
420#[derive(Debug, Deserialize)]
425pub struct RawRequest {
426 pub id: String,
427 #[serde(alias = "method")]
428 pub command: String,
429 #[serde(default)]
431 pub lsp_hints: Option<serde_json::Value>,
432 #[serde(default)]
439 pub session_id: Option<String>,
440 #[serde(flatten)]
442 pub params: serde_json::Value,
443}
444
445impl RawRequest {
446 pub fn session(&self) -> &str {
449 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
450 }
451}
452
453#[derive(Debug, Serialize)]
503pub struct Response {
504 pub id: String,
505 pub success: bool,
506 #[serde(flatten)]
507 pub data: serde_json::Value,
508}
509
510#[derive(Debug, Deserialize)]
512pub struct EchoParams {
513 pub message: String,
514}
515
516impl Response {
517 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
519 Response {
520 id: id.into(),
521 success: true,
522 data,
523 }
524 }
525
526 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
528 Response {
529 id: id.into(),
530 success: false,
531 data: serde_json::json!({
532 "code": code,
533 "message": message.into(),
534 }),
535 }
536 }
537
538 pub fn error_with_data(
542 id: impl Into<String>,
543 code: &str,
544 message: impl Into<String>,
545 extra: serde_json::Value,
546 ) -> Self {
547 let mut data = serde_json::json!({
548 "code": code,
549 "message": message.into(),
550 });
551 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
552 for (k, v) in ext {
553 base.insert(k.clone(), v.clone());
554 }
555 }
556 Response {
557 id: id.into(),
558 success: false,
559 data,
560 }
561 }
562}