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,
220 }
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use serde::Deserialize;
228 use serde_json::json;
229
230 #[derive(Debug, Deserialize)]
231 struct ConfigureWarningsFrameRoundTrip {
232 #[serde(rename = "type")]
233 frame_type: String,
234 session_id: Option<String>,
235 project_root: String,
236 source_file_count: usize,
237 max_callgraph_files: usize,
238 warnings: Vec<serde_json::Value>,
239 }
240
241 #[test]
242 fn configure_warnings_frame_serializes_null_session_id_by_default() {
243 let frame = ConfigureWarningsFrame::new(
244 "/repo",
245 42,
246 false,
247 5_000,
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.session_id, None);
260 }
261
262 #[test]
263 fn configure_warnings_frame_serializes_session_id() {
264 let frame = ConfigureWarningsFrame::new_with_session_id(
265 Some("session-1".to_string()),
266 "/repo",
267 42,
268 false,
269 5_000,
270 vec![json!({
271 "kind": "formatter_not_installed",
272 "tool": "biome",
273 "hint": "Install biome."
274 })],
275 );
276
277 let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
278 let decoded: ConfigureWarningsFrameRoundTrip =
279 serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
280
281 assert_eq!(decoded.frame_type, "configure_warnings");
282 assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
283 assert_eq!(decoded.project_root, "/repo");
284 assert_eq!(decoded.source_file_count, 42);
285 assert_eq!(decoded.max_callgraph_files, 5_000);
286 assert_eq!(decoded.warnings[0]["tool"], "biome");
287 }
288
289 #[test]
290 fn status_changed_frame_serializes_correctly() {
291 let frame = StatusChangedFrame::new(
292 None,
293 json!({
294 "version": "0.24.0",
295 "project_root": "/repo",
296 "cache_role": "main",
297 "canonical_root": "/repo",
298 "search_index": { "status": "ready" },
299 "semantic_index": { "status": "disabled" },
300 }),
301 );
302
303 let json = serde_json::to_value(PushFrame::StatusChanged(frame)).unwrap();
304 assert_eq!(json["type"], "status_changed");
305 assert!(json["session_id"].is_null());
306 assert_eq!(json["snapshot"]["cache_role"], "main");
307 assert_eq!(json["snapshot"]["project_root"], "/repo");
308 }
309}
310
311impl BashCompletedFrame {
312 pub fn new(
313 task_id: impl Into<String>,
314 session_id: impl Into<String>,
315 status: BgTaskStatus,
316 exit_code: Option<i32>,
317 command: impl Into<String>,
318 output_preview: impl Into<String>,
319 output_truncated: bool,
320 original_tokens: Option<u32>,
321 compressed_tokens: Option<u32>,
322 tokens_skipped: bool,
323 ) -> Self {
324 Self {
325 frame_type: "bash_completed",
326 task_id: task_id.into(),
327 session_id: session_id.into(),
328 status,
329 exit_code,
330 command: command.into(),
331 output_preview: output_preview.into(),
332 output_truncated,
333 original_tokens,
334 compressed_tokens,
335 tokens_skipped,
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}