1use serde::Serialize;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Copy, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum SessionMode {
17 Headless,
18 Tui,
19 Ide,
20 Vscode,
21 AtomcodeAir,
22}
23
24#[derive(Debug, Clone, Serialize)]
27pub struct Envelope {
28 pub device_id: Uuid,
29 pub launch_id: Uuid,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub account_id: Option<String>,
32 pub session_id: Uuid,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub turn_id: Option<Uuid>,
35 pub ts: i64,
36 pub schema_version: u32,
37 pub app_version: String,
38 pub os: String,
39 pub arch: String,
40 pub locale: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub provider: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
47 pub provider_host: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub model: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub repo_origin: Option<RepoOrigin>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub mode: Option<SessionMode>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct RepoOrigin {
58 pub host: RepoHost,
59 pub has_git: bool,
60}
61
62#[derive(Debug, Clone, Copy, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum RepoHost {
65 Gitcode,
66 Atomgit,
67 Github,
68 Gitlab,
69 Other,
70 None,
71}
72
73#[derive(Debug, Clone, Copy, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum LlmErrorKind {
79 NetworkError,
80 AuthError,
81 RateLimited,
82 ServerError,
83 StreamInterrupted,
84 StreamTimeout,
85 ContextOverflow,
86 Other,
87}
88
89#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
91#[serde(rename_all = "snake_case")]
92pub enum ToolErrorKind {
93 NotFound,
94 InvalidArgs,
95 ExecutionFailed,
96 DeniedByUser,
97 BlockedByHook,
98 LoopDetected,
99 SkillNotFound,
100 SkillDisabled,
101 SkillEmptyTemplate,
102 Warning,
105 Other,
106}
107
108#[derive(Debug, Clone, Copy, Serialize)]
110#[serde(rename_all = "snake_case")]
111pub enum McpErrorKind {
112 NetworkError,
113 AuthError,
114 ServerError,
115 ExecutionFailed,
116 Timeout,
117 Other,
118}
119
120#[derive(Debug, Clone, Copy, Serialize)]
122#[serde(rename_all = "snake_case")]
123pub enum CodingplanErrorKind {
124 AuthError,
125 AuthExpired,
126 ExecutionFailed,
127 NetworkError,
128 ServerError,
129 Other,
130}
131
132#[derive(Debug, Clone, Copy, Serialize)]
134#[serde(rename_all = "snake_case")]
135pub enum UseCommandErrorKind {
136 ExecutionFailed,
137 InvalidArgs,
138 NotFound,
139 Other,
140}
141
142#[derive(Debug, Clone, Copy, Serialize)]
144#[serde(rename_all = "snake_case")]
145pub enum McpTransport {
146 Stdio,
147 Sse,
148 StreamableHttp,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "snake_case")]
155pub enum CodingplanResult {
156 Success,
157 Fail,
158}
159
160#[derive(Debug, Clone, Serialize)]
163#[serde(tag = "event_id", rename_all = "snake_case")]
164pub enum Event {
165 OpenAtomcode,
168
169 LlmChat {
171 duration_ms: u32,
172 tool_calls_count: u32,
173 input_tokens: u32,
174 output_tokens: u32,
175 cached_tokens: u32,
176 had_error: bool,
177 context_window: u32,
178 system_tokens: u32,
179 tool_def_tokens: u32,
180 tool_result_tokens: u32,
181 message_tokens: u32,
182 messages_count: u32,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 error_kind: Option<LlmErrorKind>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 error_data: Option<String>,
189 },
190
191 ToolCall {
193 name: String,
195 success: bool,
197 duration_ms: u32,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 error_kind: Option<ToolErrorKind>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 error_data: Option<String>,
205 },
206
207 UseCommand {
210 #[serde(rename = "type")]
211 type_: String,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 success: Option<bool>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 error_kind: Option<UseCommandErrorKind>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 error_data: Option<String>,
221 },
222
223 McpConnect {
225 server_name: String,
227 transport: McpTransport,
229 success: bool,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 duration_ms: Option<u32>,
234 #[serde(skip_serializing_if = "Option::is_none")]
236 error_kind: Option<McpErrorKind>,
237 #[serde(skip_serializing_if = "Option::is_none")]
239 error_data: Option<String>,
240 },
241
242 LoginSuccess,
244
245 TakeCodingplan {
247 #[serde(rename = "type")]
248 type_: CodingplanResult,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 error_kind: Option<CodingplanErrorKind>,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 error_data: Option<String>,
255 },
256
257 Panic {
259 location: String,
260 message_head: String,
261 thread: String,
262 backtrace_top_5: Vec<String>,
263 #[serde(skip_serializing_if = "Option::is_none")]
265 error_kind: Option<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 error_data: Option<String>,
269 },
270
271 TelemetryDisabled,
274
275 CodingplanOfficialBuildRequired,
280}
281
282#[derive(Debug, Clone, Serialize)]
285pub struct Record {
286 #[serde(flatten)]
287 pub envelope: Envelope,
288 #[serde(flatten)]
289 pub event: Event,
290}
291
292#[cfg(test)]
295mod tests {
296 use super::*;
297 use serde_json;
298
299 fn sample_envelope() -> Envelope {
300 Envelope {
301 device_id: Uuid::nil(),
302 launch_id: Uuid::nil(),
303 account_id: None,
304 session_id: Uuid::nil(),
305 turn_id: None,
306 ts: 0,
307 schema_version: 1,
308 app_version: "0.0.0".into(),
309 os: "linux".into(),
310 arch: "x86_64".into(),
311 locale: "en-US".into(),
312 provider: None,
313 provider_host: None,
314 model: None,
315 repo_origin: None,
316 mode: None,
317 }
318 }
319
320 #[test]
321 fn envelope_omits_none_fields() {
322 let s = serde_json::to_string(&sample_envelope()).unwrap();
323 assert!(!s.contains("account_id"));
324 assert!(!s.contains("turn_id"));
325 assert!(!s.contains("provider"));
326 assert!(!s.contains("repo_origin"));
327 assert!(!s.contains("mode"));
328 }
329
330 #[test]
331 fn envelope_carries_session_mode() {
332 let mut env = sample_envelope();
333 env.mode = Some(SessionMode::Headless);
334 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
335 assert_eq!(v["mode"], "headless");
336
337 env.mode = Some(SessionMode::Tui);
338 let v: serde_json::Value = serde_json::to_value(&env).unwrap();
339 assert_eq!(v["mode"], "tui");
340 }
341
342 #[test]
343 fn record_flattens_envelope_and_event() {
344 let r = Record {
345 envelope: sample_envelope(),
346 event: Event::OpenAtomcode,
347 };
348 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
349 assert_eq!(v["event_id"], "open_atomcode");
350 assert_eq!(v["schema_version"], 1);
351 assert!(v.get("device_id").is_some());
353 }
354
355 #[test]
356 fn use_command_serializes_type_field() {
357 let r = Record {
358 envelope: sample_envelope(),
359 event: Event::UseCommand {
360 type_: "compact".into(),
361 success: None,
362 error_kind: None,
363 error_data: None,
364 },
365 };
366 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
367 assert_eq!(v["event_id"], "use_command");
368 assert_eq!(v["type"], "compact");
369 assert!(v.get("success").is_none());
371 assert!(v.get("error_kind").is_none());
372 assert!(v.get("error_data").is_none());
373 }
374
375 #[test]
376 fn use_command_with_error_fields() {
377 let r = Record {
378 envelope: sample_envelope(),
379 event: Event::UseCommand {
380 type_: "reload".into(),
381 success: Some(false),
382 error_kind: Some(UseCommandErrorKind::ExecutionFailed),
383 error_data: Some(r#"{"command":"reload","message":"parse error"}"#.into()),
384 },
385 };
386 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
387 assert_eq!(v["event_id"], "use_command");
388 assert_eq!(v["type"], "reload");
389 assert_eq!(v["success"], false);
390 assert_eq!(v["error_kind"], "execution_failed");
391 }
392
393 #[test]
394 fn take_codingplan_serializes_success_fail() {
395 let ok = Record {
396 envelope: sample_envelope(),
397 event: Event::TakeCodingplan {
398 type_: CodingplanResult::Success,
399 error_kind: None,
400 error_data: None,
401 },
402 };
403 let fail = Record {
404 envelope: sample_envelope(),
405 event: Event::TakeCodingplan {
406 type_: CodingplanResult::Fail,
407 error_kind: Some(CodingplanErrorKind::AuthError),
408 error_data: Some(r#"{"step":"login"}"#.into()),
409 },
410 };
411 let ov: serde_json::Value = serde_json::to_value(&ok).unwrap();
412 let fv: serde_json::Value = serde_json::to_value(&fail).unwrap();
413 assert_eq!(ov["event_id"], "take_codingplan");
414 assert_eq!(ov["type"], "success");
415 assert!(ov.get("error_kind").is_none());
416 assert_eq!(fv["type"], "fail");
417 assert_eq!(fv["error_kind"], "auth_error");
418 }
419
420 #[test]
421 fn llm_chat_payload_shape() {
422 let r = Record {
423 envelope: sample_envelope(),
424 event: Event::LlmChat {
425 duration_ms: 100,
426 tool_calls_count: 2,
427 input_tokens: 500,
428 output_tokens: 300,
429 cached_tokens: 0,
430 had_error: false,
431 context_window: 200000,
432 system_tokens: 100,
433 tool_def_tokens: 200,
434 tool_result_tokens: 0,
435 message_tokens: 50,
436 messages_count: 5,
437 error_kind: None,
438 error_data: None,
439 },
440 };
441 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
442 assert_eq!(v["event_id"], "llm_chat");
443 assert_eq!(v["duration_ms"], 100);
444 assert_eq!(v["tool_calls_count"], 2);
445 assert_eq!(v["had_error"], false);
446 assert_eq!(v["context_window"], 200000);
447 assert_eq!(v["system_tokens"], 100);
448 assert_eq!(v["tool_def_tokens"], 200);
449 assert_eq!(v["message_tokens"], 50);
450 assert_eq!(v["messages_count"], 5);
451 assert!(v.get("context_used").is_none());
452 assert!(v.get("error_kind").is_none());
454 assert!(v.get("error_data").is_none());
455 }
456
457 #[test]
458 fn llm_chat_with_error() {
459 let r = Record {
460 envelope: sample_envelope(),
461 event: Event::LlmChat {
462 duration_ms: 5000,
463 tool_calls_count: 0,
464 input_tokens: 100,
465 output_tokens: 0,
466 cached_tokens: 0,
467 had_error: true,
468 context_window: 200000,
469 system_tokens: 100,
470 tool_def_tokens: 0,
471 tool_result_tokens: 0,
472 message_tokens: 0,
473 messages_count: 1,
474 error_kind: Some(LlmErrorKind::AuthError),
475 error_data: Some(r#"{"status_code":401,"message":"Invalid API key"}"#.into()),
476 },
477 };
478 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
479 assert_eq!(v["had_error"], true);
480 assert_eq!(v["error_kind"], "auth_error");
481 assert!(v["error_data"].is_string());
482 }
483
484 #[test]
485 fn tool_call_success_omits_error_fields() {
486 let r = Record {
487 envelope: sample_envelope(),
488 event: Event::ToolCall {
489 name: "bash".into(),
490 success: true,
491 duration_ms: 150,
492 error_kind: None,
493 error_data: None,
494 },
495 };
496 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
497 assert_eq!(v["event_id"], "tool_call");
498 assert_eq!(v["name"], "bash");
499 assert_eq!(v["success"], true);
500 assert_eq!(v["duration_ms"], 150);
501 assert!(v.get("error_kind").is_none());
502 assert!(v.get("error_data").is_none());
503 }
504
505 #[test]
506 fn tool_call_with_error() {
507 let r = Record {
508 envelope: sample_envelope(),
509 event: Event::ToolCall {
510 name: "edit_file".into(),
511 success: false,
512 duration_ms: 5,
513 error_kind: Some(ToolErrorKind::DeniedByUser),
514 error_data: Some(
515 serde_json::json!({
516 "tool_name": "edit_file",
517 "reason": "User rejected file write",
518 "resolution": "Confirm the edit when prompted"
519 }).to_string(),
520 ),
521 },
522 };
523 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
524 assert_eq!(v["event_id"], "tool_call");
525 assert_eq!(v["name"], "edit_file");
526 assert_eq!(v["success"], false);
527 assert_eq!(v["error_kind"], "denied_by_user");
528 assert!(v["error_data"].is_string());
529 let ed: serde_json::Value =
531 serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
532 assert_eq!(ed["reason"], "User rejected file write");
533 assert_eq!(ed["resolution"], "Confirm the edit when prompted");
534 }
535
536 #[test]
537 fn tool_call_warning_with_stderr() {
538 let r = Record {
539 envelope: sample_envelope(),
540 event: Event::ToolCall {
541 name: "bash".into(),
542 success: true,
543 duration_ms: 43,
544 error_kind: Some(ToolErrorKind::Warning),
545 error_data: Some(
546 serde_json::json!({
547 "tool_name": "bash",
548 "duration_ms": 43,
549 "args_summary": "bash(command=rm -rf /tmp/test.txt)",
550 "output_tail": "rm: /tmp/test.txt: No such file or directory\n[elapsed: 0.0s, exit: 0]",
551 "reason": "Command succeeded (exit 0) but produced stderr output",
552 "resolution": "Review stderr for potential issues; the command may not have had the intended effect",
553 }).to_string(),
554 ),
555 },
556 };
557 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
558 assert_eq!(v["event_id"], "tool_call");
559 assert_eq!(v["success"], true);
560 assert_eq!(v["error_kind"], "warning");
561 let ed: serde_json::Value =
562 serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
563 assert_eq!(ed["reason"], "Command succeeded (exit 0) but produced stderr output");
564 assert_eq!(ed["resolution"], "Review stderr for potential issues; the command may not have had the intended effect");
565 }
566
567 #[test]
568 fn mcp_connect_success_omits_error_fields() {
569 let r = Record {
570 envelope: sample_envelope(),
571 event: Event::McpConnect {
572 server_name: "github".into(),
573 transport: McpTransport::Stdio,
574 success: true,
575 duration_ms: Some(320),
576 error_kind: None,
577 error_data: None,
578 },
579 };
580 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
581 assert_eq!(v["event_id"], "mcp_connect");
582 assert_eq!(v["server_name"], "github");
583 assert_eq!(v["transport"], "stdio");
584 assert_eq!(v["success"], true);
585 assert_eq!(v["duration_ms"], 320);
586 assert!(v.get("error_kind").is_none());
587 assert!(v.get("error_data").is_none());
588 }
589
590 #[test]
591 fn mcp_connect_with_error() {
592 let r = Record {
593 envelope: sample_envelope(),
594 event: Event::McpConnect {
595 server_name: "remote-api".into(),
596 transport: McpTransport::Sse,
597 success: false,
598 duration_ms: Some(5000),
599 error_kind: Some(McpErrorKind::Timeout),
600 error_data: Some(
601 serde_json::json!({
602 "server_name": "remote-api",
603 "transport": "sse",
604 "reason": "Connection timed out after 5s",
605 "resolution": "Check MCP server URL and network connectivity"
606 }).to_string(),
607 ),
608 },
609 };
610 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
611 assert_eq!(v["event_id"], "mcp_connect");
612 assert_eq!(v["server_name"], "remote-api");
613 assert_eq!(v["transport"], "sse");
614 assert_eq!(v["success"], false);
615 assert_eq!(v["error_kind"], "timeout");
616 let ed: serde_json::Value =
617 serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
618 assert_eq!(ed["reason"], "Connection timed out after 5s");
619 assert_eq!(ed["resolution"], "Check MCP server URL and network connectivity");
620 }
621
622 #[test]
623 fn panic_without_error_fields() {
624 let r = Record {
625 envelope: sample_envelope(),
626 event: Event::Panic {
627 location: "src/main.rs:42".into(),
628 message_head: "index out of bounds".into(),
629 thread: "main".into(),
630 backtrace_top_5: vec!["func_a".into(), "func_b".into()],
631 error_kind: None,
632 error_data: None,
633 },
634 };
635 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
636 assert_eq!(v["event_id"], "panic");
637 assert_eq!(v["location"], "src/main.rs:42");
638 assert_eq!(v["message_head"], "index out of bounds");
639 assert!(v.get("error_kind").is_none());
640 assert!(v.get("error_data").is_none());
641 }
642
643 #[test]
644 fn panic_with_error_context() {
645 let r = Record {
646 envelope: sample_envelope(),
647 event: Event::Panic {
648 location: "src/agent.rs:100".into(),
649 message_head: "assertion failed".into(),
650 thread: "tokio-runtime".into(),
651 backtrace_top_5: vec![],
652 error_kind: Some("panic".into()),
653 error_data: Some(
654 serde_json::json!({
655 "session_duration_secs": 300,
656 "turns_completed": 5,
657 "last_tool_name": "bash",
658 "last_event": "llm_chat"
659 }).to_string(),
660 ),
661 },
662 };
663 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
664 assert_eq!(v["event_id"], "panic");
665 assert_eq!(v["error_kind"], "panic");
666 let ed: serde_json::Value =
667 serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
668 assert_eq!(ed["session_duration_secs"], 300);
669 assert_eq!(ed["turns_completed"], 5);
670 }
671
672 #[test]
673 fn all_variants_have_event_id_tag() {
674 let cases = [
675 Event::OpenAtomcode,
676 Event::LlmChat {
677 duration_ms: 0,
678 tool_calls_count: 0,
679 input_tokens: 0,
680 output_tokens: 0,
681 cached_tokens: 0,
682 had_error: false,
683 context_window: 0,
684 system_tokens: 0,
685 tool_def_tokens: 0,
686 tool_result_tokens: 0,
687 message_tokens: 0,
688 messages_count: 0,
689 error_kind: None,
690 error_data: None,
691 },
692 Event::ToolCall {
693 name: "bash".into(),
694 success: true,
695 duration_ms: 0,
696 error_kind: None,
697 error_data: None,
698 },
699 Event::UseCommand {
700 type_: "x".into(),
701 success: None,
702 error_kind: None,
703 error_data: None,
704 },
705 Event::McpConnect {
706 server_name: "test".into(),
707 transport: McpTransport::Stdio,
708 success: true,
709 duration_ms: None,
710 error_kind: None,
711 error_data: None,
712 },
713 Event::LoginSuccess,
714 Event::TakeCodingplan {
715 type_: CodingplanResult::Success,
716 error_kind: None,
717 error_data: None,
718 },
719 Event::Panic {
720 location: "x:1".into(),
721 message_head: "".into(),
722 thread: "main".into(),
723 backtrace_top_5: vec![],
724 error_kind: None,
725 error_data: None,
726 },
727 Event::TelemetryDisabled,
728 ];
729 for e in &cases {
730 let v = serde_json::to_value(e).unwrap();
731 assert!(v.get("event_id").is_some(), "missing event_id: {:?}", e);
732 }
733 assert_eq!(cases.len(), 9);
734 }
735
736 #[test]
737 fn telemetry_disabled_serializes_with_correct_event_id() {
738 let r = Record {
739 envelope: sample_envelope(),
740 event: Event::TelemetryDisabled,
741 };
742 let v: serde_json::Value = serde_json::to_value(&r).unwrap();
743 assert_eq!(v["event_id"], "telemetry_disabled");
744 }
745
746 #[test]
747 fn session_mode_ide_serializes_as_ide() {
748 assert_eq!(
749 serde_json::to_string(&SessionMode::Ide).unwrap(),
750 "\"ide\""
751 );
752 }
753}
754
755#[cfg(test)]
756mod codingplan_required_event_tests {
757 use super::*;
758
759 #[test]
760 fn codingplan_official_build_required_serialises_with_snake_case_event_id() {
761 let e = Event::CodingplanOfficialBuildRequired;
762 let v = serde_json::to_value(&e).expect("serialise");
763 assert_eq!(
764 v["event_id"], "codingplan_official_build_required",
765 "got: {v}"
766 );
767 }
768}