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,
122 pub source_file_count_exceeds_max: bool,
125 pub max_callgraph_files: usize,
127 pub warnings: Vec<serde_json::Value>,
129}
130
131#[derive(Debug, Clone, Serialize)]
132pub struct StatusChangedFrame {
133 #[serde(rename = "type")]
134 pub frame_type: &'static str,
135 #[serde(default)]
136 pub session_id: Option<String>,
137 pub snapshot: StatusPayload,
138}
139
140#[derive(Debug, Clone, Serialize)]
141#[serde(untagged)]
142pub enum PushFrame {
143 Progress(ProgressFrame),
144 BashCompleted(BashCompletedFrame),
145 BashLongRunning(BashLongRunningFrame),
146 BashPatternMatch(BashPatternMatchFrame),
147 ConfigureWarnings(ConfigureWarningsFrame),
148 StatusChanged(StatusChangedFrame),
149}
150
151impl PermissionAskFrame {
152 pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
153 Self {
154 frame_type: "permission_ask",
155 request_id: request_id.into(),
156 asks,
157 }
158 }
159}
160
161impl ProgressFrame {
162 pub fn new(
163 request_id: impl Into<String>,
164 kind: ProgressKind,
165 chunk: impl Into<String>,
166 ) -> Self {
167 Self {
168 frame_type: "progress",
169 request_id: request_id.into(),
170 kind,
171 chunk: chunk.into(),
172 }
173 }
174}
175
176impl ConfigureWarningsFrame {
177 pub fn new(
178 project_root: impl Into<String>,
179 source_file_count: usize,
180 source_file_count_exceeds_max: bool,
181 max_callgraph_files: usize,
182 warnings: Vec<serde_json::Value>,
183 ) -> Self {
184 Self::new_with_session_id(
185 None,
186 project_root,
187 source_file_count,
188 source_file_count_exceeds_max,
189 max_callgraph_files,
190 warnings,
191 )
192 }
193
194 pub fn new_with_session_id(
195 session_id: Option<String>,
196 project_root: impl Into<String>,
197 source_file_count: usize,
198 source_file_count_exceeds_max: bool,
199 max_callgraph_files: usize,
200 warnings: Vec<serde_json::Value>,
201 ) -> Self {
202 Self {
203 frame_type: "configure_warnings",
204 session_id,
205 project_root: project_root.into(),
206 source_file_count,
207 source_file_count_exceeds_max,
208 max_callgraph_files,
209 warnings,
210 }
211 }
212}
213
214impl StatusChangedFrame {
215 pub fn new(session_id: Option<String>, snapshot: StatusPayload) -> Self {
216 Self {
217 frame_type: "status_changed",
218 session_id,
219 snapshot: status_push_payload(snapshot),
220 }
221 }
222}
223
224fn status_push_payload(mut snapshot: StatusPayload) -> StatusPayload {
225 if let Some(object) = snapshot.as_object_mut() {
226 object.remove("session");
227 if let Some(compression) = object
228 .get_mut("compression")
229 .and_then(serde_json::Value::as_object_mut)
230 {
231 compression.remove("session");
232 }
233 }
234 snapshot
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use serde::Deserialize;
241 use serde_json::json;
242
243 #[derive(Debug, Deserialize)]
244 struct ConfigureWarningsFrameRoundTrip {
245 #[serde(rename = "type")]
246 frame_type: String,
247 session_id: Option<String>,
248 project_root: String,
249 source_file_count: usize,
250 max_callgraph_files: usize,
251 warnings: Vec<serde_json::Value>,
252 }
253
254 #[test]
255 fn configure_warnings_frame_serializes_null_session_id_by_default() {
256 let frame = ConfigureWarningsFrame::new(
257 "/repo",
258 42,
259 false,
260 5_000,
261 vec![json!({
262 "kind": "formatter_not_installed",
263 "tool": "biome",
264 "hint": "Install biome."
265 })],
266 );
267
268 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
269 let decoded: ConfigureWarningsFrameRoundTrip =
270 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
271
272 assert_eq!(decoded.session_id, None);
273 }
274
275 #[test]
276 fn configure_warnings_frame_serializes_session_id() {
277 let frame = ConfigureWarningsFrame::new_with_session_id(
278 Some("session-1".to_string()),
279 "/repo",
280 42,
281 false,
282 5_000,
283 vec![json!({
284 "kind": "formatter_not_installed",
285 "tool": "biome",
286 "hint": "Install biome."
287 })],
288 );
289
290 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
291 let decoded: ConfigureWarningsFrameRoundTrip =
292 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
293
294 assert_eq!(decoded.frame_type, "configure_warnings");
295 assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
296 assert_eq!(decoded.project_root, "/repo");
297 assert_eq!(decoded.source_file_count, 42);
298 assert_eq!(decoded.max_callgraph_files, 5_000);
299 assert_eq!(decoded.warnings[0]["tool"], "biome");
300 }
301
302 #[test]
303 fn status_changed_frame_serializes_correctly() {
304 let frame = StatusChangedFrame::new(
305 None,
306 json!({
307 "version": "0.24.0",
308 "project_root": "/repo",
309 "cache_role": "main",
310 "canonical_root": "/repo",
311 "search_index": { "status": "ready" },
312 "semantic_index": { "status": "disabled" },
313 }),
314 );
315
316 let json = serde_json::to_value(PushFrame::StatusChanged(frame)).unwrap();
317 assert_eq!(json["type"], "status_changed");
318 assert!(json["session_id"].is_null());
319 assert_eq!(json["snapshot"]["cache_role"], "main");
320 assert_eq!(json["snapshot"]["project_root"], "/repo");
321 }
322
323 #[test]
324 fn status_changed_frame_strips_session_scoped_push_fields() {
325 let frame = StatusChangedFrame::new(
326 None,
327 json!({
328 "version": "0.24.0",
329 "checkpoints_total": 7,
330 "session": { "id": "default", "tracked_files": 2, "checkpoints": 1 },
331 "compression": {
332 "project": { "events": 3 },
333 "session": { "events": 99 }
334 }
335 }),
336 );
337
338 assert!(frame.snapshot.get("session").is_none());
339 assert_eq!(frame.snapshot["checkpoints_total"], 7);
340 assert_eq!(frame.snapshot["compression"]["project"]["events"], 3);
341 assert!(frame.snapshot["compression"].get("session").is_none());
342 }
343}
344
345impl BashCompletedFrame {
346 pub fn new(
347 task_id: impl Into<String>,
348 session_id: impl Into<String>,
349 status: BgTaskStatus,
350 exit_code: Option<i32>,
351 command: impl Into<String>,
352 output_preview: impl Into<String>,
353 output_truncated: bool,
354 original_tokens: Option<u32>,
355 compressed_tokens: Option<u32>,
356 tokens_skipped: bool,
357 ) -> Self {
358 Self {
359 frame_type: "bash_completed",
360 task_id: task_id.into(),
361 session_id: session_id.into(),
362 status,
363 exit_code,
364 command: command.into(),
365 output_preview: output_preview.into(),
366 output_truncated,
367 original_tokens,
368 compressed_tokens,
369 tokens_skipped,
370 }
371 }
372}
373
374impl BashLongRunningFrame {
375 pub fn new(
376 task_id: impl Into<String>,
377 session_id: impl Into<String>,
378 command: impl Into<String>,
379 elapsed_ms: u64,
380 ) -> Self {
381 Self {
382 frame_type: "bash_long_running",
383 task_id: task_id.into(),
384 session_id: session_id.into(),
385 command: command.into(),
386 elapsed_ms,
387 }
388 }
389}
390
391impl BashPatternMatchFrame {
392 pub fn new(
393 task_id: impl Into<String>,
394 session_id: impl Into<String>,
395 watch_id: impl Into<String>,
396 match_text: impl Into<String>,
397 match_offset: u64,
398 context: impl Into<String>,
399 once: bool,
400 ) -> Self {
401 Self {
402 frame_type: "bash_pattern_match",
403 task_id: task_id.into(),
404 session_id: session_id.into(),
405 watch_id: watch_id.into(),
406 match_text: match_text.into(),
407 match_offset,
408 context: context.into(),
409 once,
410 reason: "pattern_match",
411 }
412 }
413
414 pub fn task_exit(
415 task_id: impl Into<String>,
416 session_id: impl Into<String>,
417 match_text: impl Into<String>,
418 context: impl Into<String>,
419 ) -> Self {
420 Self {
421 frame_type: "bash_pattern_match",
422 task_id: task_id.into(),
423 session_id: session_id.into(),
424 watch_id: "exit".to_string(),
425 match_text: match_text.into(),
426 match_offset: 0,
427 context: context.into(),
428 once: true,
429 reason: "task_exit",
430 }
431 }
432}
433
434pub const DEFAULT_SESSION_ID: &str = "__default__";
444
445#[derive(Debug, Deserialize)]
450pub struct RawRequest {
451 pub id: String,
452 #[serde(alias = "method")]
453 pub command: String,
454 #[serde(default)]
456 pub lsp_hints: Option<serde_json::Value>,
457 #[serde(default)]
464 pub session_id: Option<String>,
465 #[serde(flatten)]
467 pub params: serde_json::Value,
468}
469
470impl RawRequest {
471 pub fn session(&self) -> &str {
474 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
475 }
476}
477
478#[derive(Debug, Serialize)]
528pub struct Response {
529 pub id: String,
530 pub success: bool,
531 #[serde(flatten)]
532 pub data: serde_json::Value,
533}
534
535#[derive(Debug, Deserialize)]
537pub struct EchoParams {
538 pub message: String,
539}
540
541impl Response {
542 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
544 Response {
545 id: id.into(),
546 success: true,
547 data,
548 }
549 }
550
551 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
553 Response {
554 id: id.into(),
555 success: false,
556 data: serde_json::json!({
557 "code": code,
558 "message": message.into(),
559 }),
560 }
561 }
562
563 pub fn error_with_data(
567 id: impl Into<String>,
568 code: &str,
569 message: impl Into<String>,
570 extra: serde_json::Value,
571 ) -> Self {
572 let mut data = serde_json::json!({
573 "code": code,
574 "message": message.into(),
575 });
576 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
577 for (k, v) in ext {
578 base.insert(k.clone(), v.clone());
579 }
580 }
581 Response {
582 id: id.into(),
583 success: false,
584 data,
585 }
586 }
587}