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 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub status_reason: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize)]
79pub struct BashLongRunningFrame {
80 #[serde(rename = "type")]
81 pub frame_type: &'static str,
82 pub task_id: String,
83 pub session_id: String,
84 pub command: String,
85 pub elapsed_ms: u64,
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct BashPatternMatchFrame {
90 #[serde(rename = "type")]
91 pub frame_type: &'static str,
92 pub task_id: String,
93 pub session_id: String,
94 pub watch_id: String,
95 pub match_text: String,
96 pub match_offset: u64,
97 pub context: String,
98 pub once: bool,
99 pub reason: &'static str,
100}
101
102#[derive(Debug, Clone, Serialize)]
110pub struct ConfigureWarningsFrame {
111 #[serde(rename = "type")]
112 pub frame_type: &'static str,
113 #[serde(default)]
117 pub session_id: Option<String>,
118 pub project_root: String,
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(project_root: impl Into<String>, warnings: Vec<serde_json::Value>) -> Self {
172 Self::new_with_session_id(None, project_root, warnings)
173 }
174
175 pub fn new_with_session_id(
176 session_id: Option<String>,
177 project_root: impl Into<String>,
178 warnings: Vec<serde_json::Value>,
179 ) -> Self {
180 Self {
181 frame_type: "configure_warnings",
182 session_id,
183 project_root: project_root.into(),
184 warnings,
185 }
186 }
187}
188
189impl StatusChangedFrame {
190 pub fn new(session_id: Option<String>, snapshot: StatusPayload) -> Self {
191 Self {
192 frame_type: "status_changed",
193 session_id,
194 snapshot: status_push_payload(snapshot),
195 }
196 }
197}
198
199fn status_push_payload(mut snapshot: StatusPayload) -> StatusPayload {
200 if let Some(object) = snapshot.as_object_mut() {
201 object.remove("session");
202 if let Some(compression) = object
203 .get_mut("compression")
204 .and_then(serde_json::Value::as_object_mut)
205 {
206 compression.remove("session");
207 }
208 }
209 snapshot
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use serde::Deserialize;
216 use serde_json::json;
217
218 #[derive(Debug, Deserialize)]
219 struct ConfigureWarningsFrameRoundTrip {
220 #[serde(rename = "type")]
221 frame_type: String,
222 session_id: Option<String>,
223 project_root: String,
224 warnings: Vec<serde_json::Value>,
225 }
226
227 #[test]
228 fn configure_warnings_frame_serializes_null_session_id_by_default() {
229 let frame = ConfigureWarningsFrame::new(
230 "/repo",
231 vec![json!({
232 "kind": "formatter_not_installed",
233 "tool": "biome",
234 "hint": "Install biome."
235 })],
236 );
237
238 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
239 let decoded: ConfigureWarningsFrameRoundTrip =
240 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
241
242 assert_eq!(decoded.session_id, None);
243 }
244
245 #[test]
246 fn configure_warnings_frame_serializes_session_id() {
247 let frame = ConfigureWarningsFrame::new_with_session_id(
248 Some("session-1".to_string()),
249 "/repo",
250 vec![json!({
251 "kind": "formatter_not_installed",
252 "tool": "biome",
253 "hint": "Install biome."
254 })],
255 );
256
257 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
258 let decoded: ConfigureWarningsFrameRoundTrip =
259 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
260
261 assert_eq!(decoded.frame_type, "configure_warnings");
262 assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
263 assert_eq!(decoded.project_root, "/repo");
264 assert_eq!(decoded.warnings[0]["tool"], "biome");
265 }
266
267 #[test]
268 fn status_changed_frame_serializes_correctly() {
269 let frame = StatusChangedFrame::new(
270 None,
271 json!({
272 "version": "0.24.0",
273 "project_root": "/repo",
274 "cache_role": "main",
275 "canonical_root": "/repo",
276 "search_index": { "status": "ready" },
277 "semantic_index": { "status": "disabled" },
278 }),
279 );
280
281 let json = serde_json::to_value(PushFrame::StatusChanged(frame)).unwrap();
282 assert_eq!(json["type"], "status_changed");
283 assert!(json["session_id"].is_null());
284 assert_eq!(json["snapshot"]["cache_role"], "main");
285 assert_eq!(json["snapshot"]["project_root"], "/repo");
286 }
287
288 #[test]
289 fn status_changed_frame_strips_session_scoped_push_fields() {
290 let frame = StatusChangedFrame::new(
291 None,
292 json!({
293 "version": "0.24.0",
294 "checkpoints_total": 7,
295 "session": { "id": "default", "tracked_files": 2, "checkpoints": 1 },
296 "compression": {
297 "project": { "events": 3 },
298 "session": { "events": 99 }
299 }
300 }),
301 );
302
303 assert!(frame.snapshot.get("session").is_none());
304 assert_eq!(frame.snapshot["checkpoints_total"], 7);
305 assert_eq!(frame.snapshot["compression"]["project"]["events"], 3);
306 assert!(frame.snapshot["compression"].get("session").is_none());
307 }
308}
309
310impl BashCompletedFrame {
311 pub fn new(
312 task_id: impl Into<String>,
313 session_id: impl Into<String>,
314 status: BgTaskStatus,
315 exit_code: Option<i32>,
316 command: impl Into<String>,
317 output_preview: impl Into<String>,
318 output_truncated: bool,
319 original_tokens: Option<u32>,
320 compressed_tokens: Option<u32>,
321 tokens_skipped: bool,
322 ) -> Self {
323 Self {
324 frame_type: "bash_completed",
325 task_id: task_id.into(),
326 session_id: session_id.into(),
327 status,
328 exit_code,
329 command: command.into(),
330 output_preview: output_preview.into(),
331 output_truncated,
332 original_tokens,
333 compressed_tokens,
334 tokens_skipped,
335 status_reason: None,
336 }
337 }
338}
339
340impl BashLongRunningFrame {
341 pub fn new(
342 task_id: impl Into<String>,
343 session_id: impl Into<String>,
344 command: impl Into<String>,
345 elapsed_ms: u64,
346 ) -> Self {
347 Self {
348 frame_type: "bash_long_running",
349 task_id: task_id.into(),
350 session_id: session_id.into(),
351 command: command.into(),
352 elapsed_ms,
353 }
354 }
355}
356
357impl BashPatternMatchFrame {
358 pub fn new(
359 task_id: impl Into<String>,
360 session_id: impl Into<String>,
361 watch_id: impl Into<String>,
362 match_text: impl Into<String>,
363 match_offset: u64,
364 context: impl Into<String>,
365 once: bool,
366 ) -> Self {
367 Self {
368 frame_type: "bash_pattern_match",
369 task_id: task_id.into(),
370 session_id: session_id.into(),
371 watch_id: watch_id.into(),
372 match_text: match_text.into(),
373 match_offset,
374 context: context.into(),
375 once,
376 reason: "pattern_match",
377 }
378 }
379
380 pub fn task_exit(
381 task_id: impl Into<String>,
382 session_id: impl Into<String>,
383 match_text: impl Into<String>,
384 context: impl Into<String>,
385 ) -> Self {
386 Self {
387 frame_type: "bash_pattern_match",
388 task_id: task_id.into(),
389 session_id: session_id.into(),
390 watch_id: "exit".to_string(),
391 match_text: match_text.into(),
392 match_offset: 0,
393 context: context.into(),
394 once: true,
395 reason: "task_exit",
396 }
397 }
398
399 pub fn watch_target_erased(
400 task_id: impl Into<String>,
401 session_id: impl Into<String>,
402 watch_id: impl Into<String>,
403 match_text: impl Into<String>,
404 context: impl Into<String>,
405 ) -> Self {
406 Self {
407 frame_type: "bash_pattern_match",
408 task_id: task_id.into(),
409 session_id: session_id.into(),
410 watch_id: watch_id.into(),
411 match_text: match_text.into(),
412 match_offset: 0,
413 context: context.into(),
414 once: true,
415 reason: "task_exit",
416 }
417 }
418}
419
420pub const DEFAULT_SESSION_ID: &str = "__default__";
430
431#[derive(Debug, Deserialize)]
436pub struct RawRequest {
437 pub id: String,
438 #[serde(alias = "method")]
439 pub command: String,
440 #[serde(default)]
442 pub lsp_hints: Option<serde_json::Value>,
443 #[serde(default)]
450 pub session_id: Option<String>,
451 #[serde(flatten)]
453 pub params: serde_json::Value,
454}
455
456impl RawRequest {
457 pub fn session(&self) -> &str {
460 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
461 }
462}
463
464#[derive(Debug, Serialize)]
514pub struct Response {
515 pub id: String,
516 pub success: bool,
517 #[serde(flatten)]
518 pub data: serde_json::Value,
519}
520
521#[derive(Debug, Deserialize)]
523pub struct EchoParams {
524 pub message: String,
525}
526
527impl Response {
528 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
530 Response {
531 id: id.into(),
532 success: true,
533 data,
534 }
535 }
536
537 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
539 Response {
540 id: id.into(),
541 success: false,
542 data: serde_json::json!({
543 "code": code,
544 "message": message.into(),
545 }),
546 }
547 }
548
549 pub fn error_with_data(
553 id: impl Into<String>,
554 code: &str,
555 message: impl Into<String>,
556 extra: serde_json::Value,
557 ) -> Self {
558 let mut data = serde_json::json!({
559 "code": code,
560 "message": message.into(),
561 });
562 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
563 for (k, v) in ext {
564 base.insert(k.clone(), v.clone());
565 }
566 }
567 Response {
568 id: id.into(),
569 success: false,
570 data,
571 }
572 }
573}