1use serde::{Deserialize, Serialize};
2
3use crate::bash_background::process::LiveDescendant;
4use crate::bash_background::BgTaskStatus;
5use crate::list_envelope::ListEnvelope;
6
7pub type StatusPayload = serde_json::Value;
9
10pub const ERROR_PERMISSION_REQUIRED: &str = "permission_required";
20
21#[derive(Debug, Clone, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ProgressKind {
24 Stdout,
25 Stderr,
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct ProgressFrame {
30 #[serde(rename = "type")]
31 pub frame_type: &'static str,
32 pub request_id: String,
33 pub kind: ProgressKind,
34 pub chunk: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct PermissionAskFrame {
39 #[serde(rename = "type")]
40 pub frame_type: &'static str,
41 pub request_id: String,
42 pub asks: serde_json::Value,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct BashCompletedFrame {
47 #[serde(rename = "type")]
48 pub frame_type: &'static str,
49 pub task_id: String,
50 pub session_id: String,
51 pub status: BgTaskStatus,
52 pub exit_code: Option<i32>,
53 pub command: String,
54 #[serde(default)]
59 pub output_preview: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub bash_output_list_envelope: Option<ListEnvelope>,
62 #[serde(default)]
66 pub output_truncated: bool,
67 #[serde(skip_serializing_if = "Option::is_none")]
70 pub original_tokens: Option<u32>,
71 #[serde(skip_serializing_if = "Option::is_none")]
74 pub compressed_tokens: Option<u32>,
75 #[serde(default)]
77 pub tokens_skipped: bool,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub status_reason: Option<String>,
80 pub live_descendants: Option<Vec<LiveDescendant>>,
81 #[serde(default, skip_serializing_if = "is_zero_usize")]
82 pub live_descendants_omitted: usize,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub live_descendants_summary: Option<String>,
85}
86
87fn is_zero_usize(value: &usize) -> bool {
88 *value == 0
89}
90
91#[derive(Debug, Clone, Serialize)]
92pub struct BashLongRunningFrame {
93 #[serde(rename = "type")]
94 pub frame_type: &'static str,
95 pub task_id: String,
96 pub session_id: String,
97 pub command: String,
98 pub elapsed_ms: u64,
99}
100
101#[derive(Debug, Clone, Serialize)]
102pub struct BashPatternMatchFrame {
103 #[serde(rename = "type")]
104 pub frame_type: &'static str,
105 pub task_id: String,
106 pub session_id: String,
107 pub watch_id: String,
108 pub match_text: String,
109 pub match_offset: u64,
110 pub context: String,
111 pub once: bool,
112 pub reason: &'static str,
113}
114
115#[derive(Debug, Clone, Serialize)]
123pub struct ConfigureWarningsFrame {
124 #[serde(rename = "type")]
125 pub frame_type: &'static str,
126 #[serde(default)]
130 pub session_id: Option<String>,
131 pub project_root: String,
134 pub warnings: Vec<serde_json::Value>,
136}
137
138#[derive(Debug, Clone, Serialize)]
139pub struct StatusChangedFrame {
140 #[serde(rename = "type")]
141 pub frame_type: &'static str,
142 #[serde(default)]
143 pub session_id: Option<String>,
144 pub snapshot: StatusPayload,
145}
146
147#[derive(Debug, Clone, Serialize)]
148#[serde(untagged)]
149pub enum PushFrame {
150 Progress(ProgressFrame),
151 BashCompleted(BashCompletedFrame),
152 BashLongRunning(BashLongRunningFrame),
153 BashPatternMatch(BashPatternMatchFrame),
154 ConfigureWarnings(ConfigureWarningsFrame),
155 StatusChanged(StatusChangedFrame),
156}
157
158impl PermissionAskFrame {
159 pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
160 Self {
161 frame_type: "permission_ask",
162 request_id: request_id.into(),
163 asks,
164 }
165 }
166}
167
168impl ProgressFrame {
169 pub fn new(
170 request_id: impl Into<String>,
171 kind: ProgressKind,
172 chunk: impl Into<String>,
173 ) -> Self {
174 Self {
175 frame_type: "progress",
176 request_id: request_id.into(),
177 kind,
178 chunk: chunk.into(),
179 }
180 }
181}
182
183impl ConfigureWarningsFrame {
184 pub fn new(project_root: impl Into<String>, warnings: Vec<serde_json::Value>) -> Self {
185 Self::new_with_session_id(None, project_root, warnings)
186 }
187
188 pub fn new_with_session_id(
189 session_id: Option<String>,
190 project_root: impl Into<String>,
191 warnings: Vec<serde_json::Value>,
192 ) -> Self {
193 Self {
194 frame_type: "configure_warnings",
195 session_id,
196 project_root: project_root.into(),
197 warnings,
198 }
199 }
200}
201
202impl StatusChangedFrame {
203 pub fn new(session_id: Option<String>, snapshot: StatusPayload) -> Self {
204 Self {
205 frame_type: "status_changed",
206 session_id,
207 snapshot: status_push_payload(snapshot),
208 }
209 }
210}
211
212fn status_push_payload(mut snapshot: StatusPayload) -> StatusPayload {
213 if let Some(object) = snapshot.as_object_mut() {
214 object.remove("session");
215 if let Some(compression) = object
216 .get_mut("compression")
217 .and_then(serde_json::Value::as_object_mut)
218 {
219 compression.remove("session");
220 }
221 }
222 snapshot
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use serde::Deserialize;
229 use serde_json::json;
230
231 #[derive(Debug, Deserialize)]
232 struct ConfigureWarningsFrameRoundTrip {
233 #[serde(rename = "type")]
234 frame_type: String,
235 session_id: Option<String>,
236 project_root: String,
237 warnings: Vec<serde_json::Value>,
238 }
239
240 #[test]
241 fn configure_warnings_frame_serializes_null_session_id_by_default() {
242 let frame = ConfigureWarningsFrame::new(
243 "/repo",
244 vec![json!({
245 "kind": "formatter_not_installed",
246 "tool": "biome",
247 "hint": "Install biome."
248 })],
249 );
250
251 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
252 let decoded: ConfigureWarningsFrameRoundTrip =
253 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
254
255 assert_eq!(decoded.session_id, None);
256 }
257
258 #[test]
259 fn configure_warnings_frame_serializes_session_id() {
260 let frame = ConfigureWarningsFrame::new_with_session_id(
261 Some("session-1".to_string()),
262 "/repo",
263 vec![json!({
264 "kind": "formatter_not_installed",
265 "tool": "biome",
266 "hint": "Install biome."
267 })],
268 );
269
270 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
271 let decoded: ConfigureWarningsFrameRoundTrip =
272 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
273
274 assert_eq!(decoded.frame_type, "configure_warnings");
275 assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
276 assert_eq!(decoded.project_root, "/repo");
277 assert_eq!(decoded.warnings[0]["tool"], "biome");
278 }
279
280 #[test]
281 fn status_changed_frame_serializes_correctly() {
282 let frame = StatusChangedFrame::new(
283 None,
284 json!({
285 "version": "0.24.0",
286 "project_root": "/repo",
287 "cache_role": "main",
288 "canonical_root": "/repo",
289 "search_index": { "status": "ready" },
290 "semantic_index": { "status": "disabled" },
291 }),
292 );
293
294 let json = serde_json::to_value(PushFrame::StatusChanged(frame)).unwrap();
295 assert_eq!(json["type"], "status_changed");
296 assert!(json["session_id"].is_null());
297 assert_eq!(json["snapshot"]["cache_role"], "main");
298 assert_eq!(json["snapshot"]["project_root"], "/repo");
299 }
300
301 #[test]
302 fn status_changed_frame_strips_session_scoped_push_fields() {
303 let frame = StatusChangedFrame::new(
304 None,
305 json!({
306 "version": "0.24.0",
307 "checkpoints_total": 7,
308 "session": { "id": "default", "tracked_files": 2, "checkpoints": 1 },
309 "compression": {
310 "project": { "events": 3 },
311 "session": { "events": 99 }
312 }
313 }),
314 );
315
316 assert!(frame.snapshot.get("session").is_none());
317 assert_eq!(frame.snapshot["checkpoints_total"], 7);
318 assert_eq!(frame.snapshot["compression"]["project"]["events"], 3);
319 assert!(frame.snapshot["compression"].get("session").is_none());
320 }
321}
322
323impl BashCompletedFrame {
324 pub fn new(
325 task_id: impl Into<String>,
326 session_id: impl Into<String>,
327 status: BgTaskStatus,
328 exit_code: Option<i32>,
329 command: impl Into<String>,
330 output_preview: impl Into<String>,
331 output_truncated: bool,
332 original_tokens: Option<u32>,
333 compressed_tokens: Option<u32>,
334 tokens_skipped: bool,
335 ) -> Self {
336 Self {
337 frame_type: "bash_completed",
338 task_id: task_id.into(),
339 session_id: session_id.into(),
340 status,
341 exit_code,
342 command: command.into(),
343 output_preview: output_preview.into(),
344 bash_output_list_envelope: None,
345 output_truncated,
346 original_tokens,
347 compressed_tokens,
348 tokens_skipped,
349 status_reason: None,
350 live_descendants: None,
351 live_descendants_omitted: 0,
352 live_descendants_summary: None,
353 }
354 }
355}
356
357impl BashLongRunningFrame {
358 pub fn new(
359 task_id: impl Into<String>,
360 session_id: impl Into<String>,
361 command: impl Into<String>,
362 elapsed_ms: u64,
363 ) -> Self {
364 Self {
365 frame_type: "bash_long_running",
366 task_id: task_id.into(),
367 session_id: session_id.into(),
368 command: command.into(),
369 elapsed_ms,
370 }
371 }
372}
373
374impl BashPatternMatchFrame {
375 pub fn new(
376 task_id: impl Into<String>,
377 session_id: impl Into<String>,
378 watch_id: impl Into<String>,
379 match_text: impl Into<String>,
380 match_offset: u64,
381 context: impl Into<String>,
382 once: bool,
383 ) -> Self {
384 Self {
385 frame_type: "bash_pattern_match",
386 task_id: task_id.into(),
387 session_id: session_id.into(),
388 watch_id: watch_id.into(),
389 match_text: match_text.into(),
390 match_offset,
391 context: context.into(),
392 once,
393 reason: "pattern_match",
394 }
395 }
396
397 pub fn task_exit(
398 task_id: impl Into<String>,
399 session_id: impl Into<String>,
400 match_text: impl Into<String>,
401 context: impl Into<String>,
402 ) -> Self {
403 Self {
404 frame_type: "bash_pattern_match",
405 task_id: task_id.into(),
406 session_id: session_id.into(),
407 watch_id: "exit".to_string(),
408 match_text: match_text.into(),
409 match_offset: 0,
410 context: context.into(),
411 once: true,
412 reason: "task_exit",
413 }
414 }
415
416 pub fn watch_target_erased(
417 task_id: impl Into<String>,
418 session_id: impl Into<String>,
419 watch_id: impl Into<String>,
420 match_text: impl Into<String>,
421 context: impl Into<String>,
422 ) -> Self {
423 Self {
424 frame_type: "bash_pattern_match",
425 task_id: task_id.into(),
426 session_id: session_id.into(),
427 watch_id: watch_id.into(),
428 match_text: match_text.into(),
429 match_offset: 0,
430 context: context.into(),
431 once: true,
432 reason: "task_exit",
433 }
434 }
435}
436
437pub const DEFAULT_SESSION_ID: &str = "__default__";
447
448#[derive(Debug, Deserialize)]
453pub struct RawRequest {
454 pub id: String,
455 #[serde(alias = "method")]
456 pub command: String,
457 #[serde(default)]
459 pub lsp_hints: Option<serde_json::Value>,
460 #[serde(default)]
467 pub session_id: Option<String>,
468 #[serde(flatten)]
470 pub params: serde_json::Value,
471}
472
473impl RawRequest {
474 pub fn session(&self) -> &str {
477 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
478 }
479}
480
481#[derive(Debug, Serialize)]
531pub struct Response {
532 pub id: String,
533 pub success: bool,
534 #[serde(flatten)]
535 pub data: serde_json::Value,
536}
537
538#[derive(Debug, Deserialize)]
540pub struct EchoParams {
541 pub message: String,
542}
543
544impl Response {
545 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
547 Response {
548 id: id.into(),
549 success: true,
550 data,
551 }
552 }
553
554 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
556 Response {
557 id: id.into(),
558 success: false,
559 data: serde_json::json!({
560 "code": code,
561 "message": message.into(),
562 }),
563 }
564 }
565
566 pub fn error_with_data(
570 id: impl Into<String>,
571 code: &str,
572 message: impl Into<String>,
573 extra: serde_json::Value,
574 ) -> Self {
575 let mut data = serde_json::json!({
576 "code": code,
577 "message": message.into(),
578 });
579 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
580 for (k, v) in ext {
581 base.insert(k.clone(), v.clone());
582 }
583 }
584 Response {
585 id: id.into(),
586 success: false,
587 data,
588 }
589 }
590}