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
400pub const DEFAULT_SESSION_ID: &str = "__default__";
410
411#[derive(Debug, Deserialize)]
416pub struct RawRequest {
417 pub id: String,
418 #[serde(alias = "method")]
419 pub command: String,
420 #[serde(default)]
422 pub lsp_hints: Option<serde_json::Value>,
423 #[serde(default)]
430 pub session_id: Option<String>,
431 #[serde(flatten)]
433 pub params: serde_json::Value,
434}
435
436impl RawRequest {
437 pub fn session(&self) -> &str {
440 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
441 }
442}
443
444#[derive(Debug, Serialize)]
494pub struct Response {
495 pub id: String,
496 pub success: bool,
497 #[serde(flatten)]
498 pub data: serde_json::Value,
499}
500
501#[derive(Debug, Deserialize)]
503pub struct EchoParams {
504 pub message: String,
505}
506
507impl Response {
508 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
510 Response {
511 id: id.into(),
512 success: true,
513 data,
514 }
515 }
516
517 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
519 Response {
520 id: id.into(),
521 success: false,
522 data: serde_json::json!({
523 "code": code,
524 "message": message.into(),
525 }),
526 }
527 }
528
529 pub fn error_with_data(
533 id: impl Into<String>,
534 code: &str,
535 message: impl Into<String>,
536 extra: serde_json::Value,
537 ) -> Self {
538 let mut data = serde_json::json!({
539 "code": code,
540 "message": message.into(),
541 });
542 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
543 for (k, v) in ext {
544 base.insert(k.clone(), v.clone());
545 }
546 }
547 Response {
548 id: id.into(),
549 success: false,
550 data,
551 }
552 }
553}