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 warnings: Vec<serde_json::Value>,
121}
122
123#[derive(Debug, Clone, Serialize)]
124pub struct StatusChangedFrame {
125 #[serde(rename = "type")]
126 pub frame_type: &'static str,
127 #[serde(default)]
128 pub session_id: Option<String>,
129 pub snapshot: StatusPayload,
130}
131
132#[derive(Debug, Clone, Serialize)]
133#[serde(untagged)]
134pub enum PushFrame {
135 Progress(ProgressFrame),
136 BashCompleted(BashCompletedFrame),
137 BashLongRunning(BashLongRunningFrame),
138 BashPatternMatch(BashPatternMatchFrame),
139 ConfigureWarnings(ConfigureWarningsFrame),
140 StatusChanged(StatusChangedFrame),
141}
142
143impl PermissionAskFrame {
144 pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
145 Self {
146 frame_type: "permission_ask",
147 request_id: request_id.into(),
148 asks,
149 }
150 }
151}
152
153impl ProgressFrame {
154 pub fn new(
155 request_id: impl Into<String>,
156 kind: ProgressKind,
157 chunk: impl Into<String>,
158 ) -> Self {
159 Self {
160 frame_type: "progress",
161 request_id: request_id.into(),
162 kind,
163 chunk: chunk.into(),
164 }
165 }
166}
167
168impl ConfigureWarningsFrame {
169 pub fn new(project_root: impl Into<String>, warnings: Vec<serde_json::Value>) -> Self {
170 Self::new_with_session_id(None, project_root, warnings)
171 }
172
173 pub fn new_with_session_id(
174 session_id: Option<String>,
175 project_root: impl Into<String>,
176 warnings: Vec<serde_json::Value>,
177 ) -> Self {
178 Self {
179 frame_type: "configure_warnings",
180 session_id,
181 project_root: project_root.into(),
182 warnings,
183 }
184 }
185}
186
187impl StatusChangedFrame {
188 pub fn new(session_id: Option<String>, snapshot: StatusPayload) -> Self {
189 Self {
190 frame_type: "status_changed",
191 session_id,
192 snapshot: status_push_payload(snapshot),
193 }
194 }
195}
196
197fn status_push_payload(mut snapshot: StatusPayload) -> StatusPayload {
198 if let Some(object) = snapshot.as_object_mut() {
199 object.remove("session");
200 if let Some(compression) = object
201 .get_mut("compression")
202 .and_then(serde_json::Value::as_object_mut)
203 {
204 compression.remove("session");
205 }
206 }
207 snapshot
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use serde::Deserialize;
214 use serde_json::json;
215
216 #[derive(Debug, Deserialize)]
217 struct ConfigureWarningsFrameRoundTrip {
218 #[serde(rename = "type")]
219 frame_type: String,
220 session_id: Option<String>,
221 project_root: String,
222 warnings: Vec<serde_json::Value>,
223 }
224
225 #[test]
226 fn configure_warnings_frame_serializes_null_session_id_by_default() {
227 let frame = ConfigureWarningsFrame::new(
228 "/repo",
229 vec![json!({
230 "kind": "formatter_not_installed",
231 "tool": "biome",
232 "hint": "Install biome."
233 })],
234 );
235
236 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
237 let decoded: ConfigureWarningsFrameRoundTrip =
238 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
239
240 assert_eq!(decoded.session_id, None);
241 }
242
243 #[test]
244 fn configure_warnings_frame_serializes_session_id() {
245 let frame = ConfigureWarningsFrame::new_with_session_id(
246 Some("session-1".to_string()),
247 "/repo",
248 vec![json!({
249 "kind": "formatter_not_installed",
250 "tool": "biome",
251 "hint": "Install biome."
252 })],
253 );
254
255 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
256 let decoded: ConfigureWarningsFrameRoundTrip =
257 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
258
259 assert_eq!(decoded.frame_type, "configure_warnings");
260 assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
261 assert_eq!(decoded.project_root, "/repo");
262 assert_eq!(decoded.warnings[0]["tool"], "biome");
263 }
264
265 #[test]
266 fn status_changed_frame_serializes_correctly() {
267 let frame = StatusChangedFrame::new(
268 None,
269 json!({
270 "version": "0.24.0",
271 "project_root": "/repo",
272 "cache_role": "main",
273 "canonical_root": "/repo",
274 "search_index": { "status": "ready" },
275 "semantic_index": { "status": "disabled" },
276 }),
277 );
278
279 let json = serde_json::to_value(PushFrame::StatusChanged(frame)).unwrap();
280 assert_eq!(json["type"], "status_changed");
281 assert!(json["session_id"].is_null());
282 assert_eq!(json["snapshot"]["cache_role"], "main");
283 assert_eq!(json["snapshot"]["project_root"], "/repo");
284 }
285
286 #[test]
287 fn status_changed_frame_strips_session_scoped_push_fields() {
288 let frame = StatusChangedFrame::new(
289 None,
290 json!({
291 "version": "0.24.0",
292 "checkpoints_total": 7,
293 "session": { "id": "default", "tracked_files": 2, "checkpoints": 1 },
294 "compression": {
295 "project": { "events": 3 },
296 "session": { "events": 99 }
297 }
298 }),
299 );
300
301 assert!(frame.snapshot.get("session").is_none());
302 assert_eq!(frame.snapshot["checkpoints_total"], 7);
303 assert_eq!(frame.snapshot["compression"]["project"]["events"], 3);
304 assert!(frame.snapshot["compression"].get("session").is_none());
305 }
306}
307
308impl BashCompletedFrame {
309 pub fn new(
310 task_id: impl Into<String>,
311 session_id: impl Into<String>,
312 status: BgTaskStatus,
313 exit_code: Option<i32>,
314 command: impl Into<String>,
315 output_preview: impl Into<String>,
316 output_truncated: bool,
317 original_tokens: Option<u32>,
318 compressed_tokens: Option<u32>,
319 tokens_skipped: bool,
320 ) -> Self {
321 Self {
322 frame_type: "bash_completed",
323 task_id: task_id.into(),
324 session_id: session_id.into(),
325 status,
326 exit_code,
327 command: command.into(),
328 output_preview: output_preview.into(),
329 output_truncated,
330 original_tokens,
331 compressed_tokens,
332 tokens_skipped,
333 }
334 }
335}
336
337impl BashLongRunningFrame {
338 pub fn new(
339 task_id: impl Into<String>,
340 session_id: impl Into<String>,
341 command: impl Into<String>,
342 elapsed_ms: u64,
343 ) -> Self {
344 Self {
345 frame_type: "bash_long_running",
346 task_id: task_id.into(),
347 session_id: session_id.into(),
348 command: command.into(),
349 elapsed_ms,
350 }
351 }
352}
353
354impl BashPatternMatchFrame {
355 pub fn new(
356 task_id: impl Into<String>,
357 session_id: impl Into<String>,
358 watch_id: impl Into<String>,
359 match_text: impl Into<String>,
360 match_offset: u64,
361 context: impl Into<String>,
362 once: bool,
363 ) -> Self {
364 Self {
365 frame_type: "bash_pattern_match",
366 task_id: task_id.into(),
367 session_id: session_id.into(),
368 watch_id: watch_id.into(),
369 match_text: match_text.into(),
370 match_offset,
371 context: context.into(),
372 once,
373 reason: "pattern_match",
374 }
375 }
376
377 pub fn task_exit(
378 task_id: impl Into<String>,
379 session_id: impl Into<String>,
380 match_text: impl Into<String>,
381 context: impl Into<String>,
382 ) -> Self {
383 Self {
384 frame_type: "bash_pattern_match",
385 task_id: task_id.into(),
386 session_id: session_id.into(),
387 watch_id: "exit".to_string(),
388 match_text: match_text.into(),
389 match_offset: 0,
390 context: context.into(),
391 once: true,
392 reason: "task_exit",
393 }
394 }
395}
396
397pub const DEFAULT_SESSION_ID: &str = "__default__";
407
408#[derive(Debug, Deserialize)]
413pub struct RawRequest {
414 pub id: String,
415 #[serde(alias = "method")]
416 pub command: String,
417 #[serde(default)]
419 pub lsp_hints: Option<serde_json::Value>,
420 #[serde(default)]
427 pub session_id: Option<String>,
428 #[serde(flatten)]
430 pub params: serde_json::Value,
431}
432
433impl RawRequest {
434 pub fn session(&self) -> &str {
437 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
438 }
439}
440
441#[derive(Debug, Serialize)]
491pub struct Response {
492 pub id: String,
493 pub success: bool,
494 #[serde(flatten)]
495 pub data: serde_json::Value,
496}
497
498#[derive(Debug, Deserialize)]
500pub struct EchoParams {
501 pub message: String,
502}
503
504impl Response {
505 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
507 Response {
508 id: id.into(),
509 success: true,
510 data,
511 }
512 }
513
514 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
516 Response {
517 id: id.into(),
518 success: false,
519 data: serde_json::json!({
520 "code": code,
521 "message": message.into(),
522 }),
523 }
524 }
525
526 pub fn error_with_data(
530 id: impl Into<String>,
531 code: &str,
532 message: impl Into<String>,
533 extra: serde_json::Value,
534 ) -> Self {
535 let mut data = serde_json::json!({
536 "code": code,
537 "message": message.into(),
538 });
539 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
540 for (k, v) in ext {
541 base.insert(k.clone(), v.clone());
542 }
543 }
544 Response {
545 id: id.into(),
546 success: false,
547 data,
548 }
549 }
550}