1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::id::Id;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EventMsg {
10 pub timestamp: DateTime<Utc>,
11 pub id: Id,
12 pub event: Evt,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub parent: Option<Id>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct OpMsg {
19 pub op: Op,
20 pub id: Id,
21}
22
23#[allow(clippy::large_enum_variant)]
24#[derive(Debug, Clone, Deserialize, Serialize)]
25pub enum Op {
26 StartSession(SessionOverrides),
27 UpdateSession(SessionUpdate),
28 Interrupt,
29 UserInput(String),
30 ShellInput(String),
31 Steer(String),
32 ApprovalResponse {
33 turn_id: Id,
34 responses: Vec<(String, ReviewDecision)>,
35 },
36 SlashCommand {
37 name: String,
38 args: String,
39 },
40 ResumeSession {
41 session_id: Id,
42 },
43 RegisterLocalProvider {
44 port: u16,
45 model: Option<ModelSpec>,
46 },
47 RestoreLocalProvider,
48 Compact,
50 ContextReport,
53 Goal(GoalCommand),
58 AmbientPhrase {
63 draft: String,
64 req_id: u64,
65 },
66 AmbientSuggestion {
72 recent_user: String,
73 recent_agent: String,
74 req_id: u64,
75 },
76 Shutdown,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub enum GoalCommand {
82 Set(String),
84 Clear,
86 Status,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
93pub enum AmbientKind {
94 ThinkingPhrase,
96 PromptSuggestion,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub enum Evt {
102 SessionStart(Box<SessionInitialized>),
103 SessionUpdated(Box<SessionInitialized>),
104 ExtensionRefreshed(Box<ExtensionRefreshed>),
105 SessionEnd {
108 session_id: Id,
109 reason: SessionEndReason,
110 usage: Usage,
111 },
112 UserInput(String),
113 ShellOutput {
114 command: String,
115 stdout: String,
116 stderr: String,
117 exit_code: Option<i32>,
118 },
119 AgentMessage(String),
120 Thinking(String),
121 MessageDelta(String),
122 ThinkingDelta(String),
123 Info(String),
124 InfoBlockStart {
133 id: String,
134 header: String,
135 #[serde(default)]
136 loading: bool,
137 },
138 InfoBlockAppend {
141 id: String,
142 detail: String,
143 },
144 Error(String),
145 ToolStart(ToolUse),
146 ToolUpdate(ToolUpdate),
147 ToolEnd(ToolEnd),
148 CompactStart,
149 CompactEnd {
154 #[serde(default)]
155 summary: Option<String>,
156 },
157 TurnStart {
158 turn_id: Id,
159 },
160 TurnPause {
161 turn_id: Id,
162 reason: TurnPauseReason,
163 },
164 TurnResume {
168 turn_id: Id,
169 },
170 TurnEnd {
171 turn_id: Id,
172 status: TurnEndStatus,
173 #[serde(default)]
175 steps: usize,
176 },
177 UsageUpdate {
178 usage: Usage,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
183 context: Option<ContextWindow>,
184 },
185 ContextReport(ContextBreakdown),
188 Ambient {
193 kind: AmbientKind,
194 req_id: u64,
195 text: String,
196 },
197 Goodbye,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub enum TurnPauseReason {
202 Approval { tools: Vec<ToolUse>, message: String },
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206pub enum SessionEndReason {
207 Replaced,
209 Shutdown,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub enum TurnEndStatus {
215 Completed,
216 Interrupted {
217 #[serde(skip_serializing_if = "Option::is_none")]
218 reason: Option<String>,
219 },
220 Error {
221 #[serde(default, skip_serializing_if = "Option::is_none")]
227 kind: Option<String>,
228 headline: String,
231 details: Vec<String>,
235 },
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ToolUpdate {
240 pub tool_use_id: String,
241 pub seq: u64,
242 pub message: String,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246pub enum ToolEndStatus {
247 Completed,
248 Cancelled,
249 Denied,
250 Failed,
251}
252
253impl ToolEndStatus {
254 pub fn is_error(self) -> bool {
258 !matches!(self, ToolEndStatus::Completed)
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct ToolEnd {
264 pub tool_use_id: String,
265 pub status: ToolEndStatus,
266 pub result_json: serde_json::Value,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub enum ReviewDecision {
271 Accept,
272 Skip,
273 AcceptForSession,
274 AcceptAlways,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SkillMetadata {
281 pub name: String,
282 pub description: Option<String>,
283 pub scope: Scope,
284 pub argument_hint: Option<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct SubagentMetadata {
289 pub name: String,
290 pub description: String,
291 pub scope: Scope,
292}
293
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
303pub struct ProviderSpec {
304 #[serde(alias = "name")]
305 pub id: String,
306 pub display_name: String,
307 pub base_url: String,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SessionInitialized {
312 pub model: ModelSpec,
313 pub provider: ProviderSpec,
314 pub session_id: Id,
315 pub cwd: PathBuf,
316 pub permission_mode: PermissionMode,
317}
318
319#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323pub struct SessionUpdate {
324 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub model: Option<ModelSpec>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub permission_mode: Option<PermissionMode>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct ExtensionRefreshed {
337 pub session_id: Id,
338 pub skills: Vec<SkillMetadata>,
339 pub subagents: Vec<SubagentMetadata>,
340 #[serde(default)]
341 pub mcp_servers: Vec<McpServerInfo>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct McpServerInfo {
346 pub name: String,
347 pub command: String,
348 pub args: Vec<String>,
349 pub tools: Vec<McpToolInfo>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct McpToolInfo {
354 pub name: String,
355 pub qualified_name: String,
356 pub description: String,
357 pub parameters: Vec<McpToolParam>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct McpToolParam {
362 pub name: String,
363 pub param_type: String,
364 pub required: bool,
365 pub description: String,
366}
367
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
375pub struct SessionOverrides {
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub model: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub provider: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub permission_mode: Option<PermissionMode>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub system_prompt: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub append_system_prompt: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub tools: Option<Vec<String>>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub include_tools: Option<Vec<String>>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub exclude_tools: Option<Vec<String>>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub cwd: Option<PathBuf>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub effort: Option<Effort>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub enable_auto_memory: Option<bool>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub short_prompt: Option<bool>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub no_skills: Option<bool>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
410pub struct MalformedToolArgs {
411 pub raw: String,
413 pub error: String,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418pub struct ToolUse {
419 pub id: String,
420 pub name: String,
421 pub args: serde_json::Value,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub malformed_args: Option<MalformedToolArgs>,
426 #[serde(skip_serializing_if = "Option::is_none")]
427 pub signature: Option<String>,
428}
429
430impl ToolUse {
431 pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
433 Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
434 }
435}
436
437#[derive(Debug, Clone, Default, Serialize, Deserialize)]
438pub struct ModelSpec {
439 #[serde(alias = "name")]
440 pub id: String,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub display_name: Option<String>,
443 #[serde(skip_serializing_if = "Option::is_none")]
444 pub description: Option<String>,
445 #[serde(skip_serializing_if = "Option::is_none")]
446 pub temperature: Option<f32>,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub top_p: Option<f32>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub top_k: Option<u32>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub max_tokens: Option<u32>,
453 #[serde(skip_serializing_if = "Option::is_none")]
454 pub stop_sequences: Option<Vec<String>>,
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub context_limit: Option<u32>,
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub effort: Option<Effort>,
459 #[serde(skip_serializing_if = "Option::is_none")]
460 pub support_vision: Option<bool>,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub weight_class: Option<WeightClass>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
473#[serde(rename_all = "lowercase")]
474pub enum Effort {
475 Min,
476 Low,
477 Medium,
478 High,
479 XHigh,
480 Max,
481}
482
483impl Effort {
484 pub const ALL: [Effort; 6] =
486 [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
487
488 pub fn as_str(self) -> &'static str {
490 match self {
491 Effort::Min => "min",
492 Effort::Low => "low",
493 Effort::Medium => "medium",
494 Effort::High => "high",
495 Effort::XHigh => "xhigh",
496 Effort::Max => "max",
497 }
498 }
499}
500
501impl std::fmt::Display for Effort {
502 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
503 f.write_str(self.as_str())
504 }
505}
506
507impl std::str::FromStr for Effort {
508 type Err = String;
509
510 fn from_str(s: &str) -> Result<Self, Self::Err> {
511 Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
512 format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
513 })
514 }
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
524#[serde(rename_all = "lowercase")]
525pub enum WeightClass {
526 Feather,
528 Middle,
530 Heavy,
532}
533
534impl<'de> Deserialize<'de> for WeightClass {
535 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
536 where
537 D: Deserializer<'de>,
538 {
539 let value = String::deserialize(deserializer)?;
540 match value.to_ascii_lowercase().as_str() {
541 "feather" => Ok(Self::Feather),
542 "middle" => Ok(Self::Middle),
543 "heavy" => Ok(Self::Heavy),
544 _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
545 }
546 }
547}
548
549#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
566#[serde(default)]
567pub struct Usage {
568 pub input_tokens: u32,
570 pub output_tokens: u32,
572 #[serde(skip_serializing_if = "Option::is_none")]
574 pub cache_read_tokens: Option<u32>,
575 #[serde(skip_serializing_if = "Option::is_none")]
577 pub cache_creation_tokens: Option<u32>,
578}
579
580#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
584pub struct ContextWindow {
585 pub used_tokens: u32,
588 pub limit_tokens: u32,
590}
591
592#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
600#[serde(default)]
601pub struct ContextBreakdown {
602 pub system_prompt_tokens: u32,
604 pub system_tools_tokens: u32,
606 pub mcp_tools_tokens: u32,
608 pub memory_tokens: u32,
610 pub skills_tokens: u32,
612 pub messages_tokens: u32,
614 pub used_tokens: u32,
616 pub limit_tokens: Option<u32>,
619 pub compact_buffer_tokens: u32,
622}
623
624impl Usage {
625 pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
626 Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
627 }
628
629 pub fn total(&self) -> u32 {
631 self.input_tokens.saturating_add(self.output_tokens)
632 }
633}
634
635impl std::ops::Add<Usage> for Usage {
636 type Output = Usage;
637
638 fn add(self, other: Usage) -> Usage {
639 Usage {
640 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
641 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
642 cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
643 cache_creation_tokens: add_optional_u32(
644 self.cache_creation_tokens,
645 other.cache_creation_tokens,
646 ),
647 }
648 }
649}
650
651fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
652 match (a, b) {
653 (None, None) => None,
654 _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
655 }
656}
657
658impl std::ops::AddAssign<Usage> for Usage {
659 fn add_assign(&mut self, other: Usage) {
660 *self = *self + other;
661 }
662}
663
664#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
665#[serde(rename_all = "snake_case")]
666pub enum PermissionMode {
667 #[default]
669 Strict,
670 Auto,
673 Yolo,
675}
676
677#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
678#[serde(rename_all = "kebab-case")]
679pub enum Scope {
680 Project,
681 User,
682 System,
683}
684
685#[cfg(test)]
686mod tests {
687 use super::{
688 Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
689 SessionInitialized, SessionUpdate, ToolUse, Usage, WeightClass,
690 };
691 use std::path::PathBuf;
692
693 fn model_spec(name: &str) -> ModelSpec {
694 ModelSpec {
695 id: name.to_string(),
696 display_name: None,
697 description: None,
698 temperature: None,
699 top_p: None,
700 top_k: None,
701 max_tokens: None,
702 stop_sequences: None,
703 context_limit: None,
704 effort: None,
705 support_vision: None,
706 weight_class: None,
707 }
708 }
709
710 #[test]
711 fn tool_use_without_malformed_args_remains_backward_compatible() {
712 let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
713 "id": "call-1",
714 "name": "Read",
715 "args": { "file_path": "README.md" }
716 }))
717 .unwrap();
718
719 assert!(tool_use.malformed_args.is_none());
720 let encoded = serde_json::to_value(tool_use).unwrap();
721 assert!(encoded.get("malformed_args").is_none());
722 }
723
724 #[test]
725 fn turn_end_error_kind_is_optional_on_the_wire() {
726 let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
728 "Error": { "headline": "authentication error", "details": [] }
729 }))
730 .unwrap();
731 let super::TurnEndStatus::Error { kind, .. } = &old else {
732 panic!("expected Error variant");
733 };
734 assert!(kind.is_none());
735
736 let json = serde_json::to_value(&old).unwrap();
738 assert!(json["Error"].get("kind").is_none());
739
740 let with = super::TurnEndStatus::Error {
742 kind: Some("oauth".to_string()),
743 headline: "OAuth sign-in required".to_string(),
744 details: vec![],
745 };
746 let json = serde_json::to_value(&with).unwrap();
747 assert_eq!(json["Error"]["kind"], "oauth");
748 }
749
750 #[test]
751 fn turn_end_steps_default_for_older_events() {
752 let event = super::Evt::TurnEnd {
753 turn_id: super::Id::new("turn"),
754 status: super::TurnEndStatus::Completed,
755 steps: 3,
756 };
757 let mut json = serde_json::to_value(&event).unwrap();
758 json["TurnEnd"].as_object_mut().unwrap().remove("steps");
759 let old: super::Evt = serde_json::from_value(json).unwrap();
760
761 assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
762
763 let json = serde_json::to_value(event).unwrap();
764 assert_eq!(json["TurnEnd"]["steps"], 3);
765 }
766
767 #[test]
768 fn effort_serializes_as_lowercase_tokens() {
769 let mut spec = model_spec("m");
770 spec.effort = Some(super::Effort::XHigh);
771 let json = serde_json::to_value(&spec).unwrap();
772 assert_eq!(json["effort"], "xhigh");
773
774 let json_none = serde_json::to_value(model_spec("m")).unwrap();
776 assert!(json_none.get("effort").is_none());
777
778 for level in super::Effort::ALL {
780 let parsed: ModelSpec =
781 serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
782 .unwrap();
783 assert_eq!(parsed.effort, Some(level), "round-trip {level}");
784 }
785 }
786
787 #[test]
788 fn effort_orders_min_lowest_to_max_highest() {
789 let mut sorted = super::Effort::ALL;
790 sorted.sort();
791 assert_eq!(sorted, super::Effort::ALL);
792 assert!(super::Effort::Min < super::Effort::Low);
793 assert!(super::Effort::XHigh < super::Effort::Max);
794 }
795
796 #[test]
797 fn session_overrides_effort_round_trips() {
798 let parsed: super::SessionOverrides =
799 serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
800 assert_eq!(parsed.effort, Some(super::Effort::Max));
801
802 let parsed: super::SessionOverrides =
803 serde_json::from_value(serde_json::json!({})).unwrap();
804 assert_eq!(parsed.effort, None);
805 }
806
807 #[test]
808 fn short_prompt_round_trips_and_defaults_to_unset() {
809 let parsed: super::SessionOverrides =
810 serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
811 assert_eq!(parsed.short_prompt, Some(true));
812
813 let parsed: super::SessionOverrides =
814 serde_json::from_value(serde_json::json!({})).unwrap();
815 assert_eq!(parsed.short_prompt, None);
816 }
817
818 #[test]
819 fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
820 let mut spec = model_spec("m");
821 spec.weight_class = Some(WeightClass::Heavy);
822 let json = serde_json::to_value(&spec).unwrap();
823 assert_eq!(json["weight_class"], "heavy");
824
825 let json_none = serde_json::to_value(model_spec("m")).unwrap();
827 assert!(json_none.get("weight_class").is_none());
828
829 let parsed: ModelSpec =
831 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
832 .unwrap();
833 assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
834 }
835
836 #[test]
837 fn weight_class_deserializes_case_insensitively() {
838 for (value, expected) in [
839 ("Feather", WeightClass::Feather),
840 ("MIDDLE", WeightClass::Middle),
841 ("hEaVy", WeightClass::Heavy),
842 ] {
843 let parsed: ModelSpec =
844 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
845 .unwrap();
846 assert_eq!(parsed.weight_class, Some(expected));
847 }
848 }
849
850 #[test]
851 fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
852 assert!(WeightClass::Feather < WeightClass::Middle);
853 assert!(WeightClass::Middle < WeightClass::Heavy);
854 }
855
856 fn provider_spec(name: &str) -> ProviderSpec {
857 ProviderSpec {
858 id: name.to_string(),
859 display_name: name.to_string(),
860 base_url: format!("https://api.{name}.test/v1"),
861 }
862 }
863
864 #[test]
865 fn compact_events_serde_roundtrip() {
866 let compact_start =
867 serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
868 let compact_end =
869 serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
870 .expect("serialize CompactEnd");
871
872 assert_eq!(compact_start, "\"CompactStart\"");
873 assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
874
875 assert!(matches!(
876 serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
877 Evt::CompactStart
878 ));
879 assert!(matches!(
880 serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
881 Evt::CompactEnd { summary: Some(s) } if s == "the summary"
882 ));
883 assert!(matches!(
884 serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
885 .expect("deserialize CompactEnd without summary"),
886 Evt::CompactEnd { summary: None }
887 ));
888 }
889
890 #[test]
891 fn session_end_and_turn_resume_serde_roundtrip() {
892 let session_id = Id::new("ses");
893 let end = Evt::SessionEnd {
894 session_id,
895 reason: super::SessionEndReason::Shutdown,
896 usage: Usage::new(10, 5),
897 };
898 let json = serde_json::to_string(&end).expect("serialize SessionEnd");
899 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
900 assert!(matches!(
901 decoded,
902 Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
903 if id == session_id && usage.total() == 15
904 ));
905
906 let turn_id = Id::new("op");
907 let resume = Evt::TurnResume { turn_id };
908 let json = serde_json::to_string(&resume).expect("serialize TurnResume");
909 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
910 assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
911 }
912
913 #[test]
914 fn extension_refreshed_serde_roundtrip() {
915 let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
916 session_id: Id::new("ses"),
917 skills: Vec::new(),
918 subagents: Vec::new(),
919 mcp_servers: Vec::new(),
920 }));
921
922 let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
923 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
924
925 assert!(matches!(
926 decoded,
927 Evt::ExtensionRefreshed(payload)
928 if payload.skills.is_empty() && payload.subagents.is_empty()
929 ));
930 }
931
932 #[test]
933 fn session_update_op_serde_roundtrip() {
934 let op = Op::UpdateSession(SessionUpdate {
935 model: Some(ModelSpec {
936 temperature: Some(0.2),
937 effort: Some(super::Effort::High),
938 ..model_spec("gpt-5.4")
939 }),
940 permission_mode: Some(PermissionMode::Yolo),
941 });
942
943 let json = serde_json::to_string(&op).expect("serialize UpdateSession");
944 let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
945
946 assert!(matches!(
947 decoded,
948 Op::UpdateSession(SessionUpdate {
949 model: Some(model),
950 permission_mode: Some(PermissionMode::Yolo),
951 })
952 if model.id == "gpt-5.4"
953 && model.temperature == Some(0.2)
954 && model.effort == Some(super::Effort::High)
955 ));
956 }
957
958 #[test]
959 fn session_updated_event_serde_roundtrip() {
960 let session_id = Id::new("ses");
961 let event = Evt::SessionUpdated(Box::new(SessionInitialized {
962 model: model_spec("claude-sonnet-4-6"),
963 provider: provider_spec("anthropic"),
964 session_id,
965 cwd: PathBuf::from("/tmp/session-updated"),
966 permission_mode: PermissionMode::default(),
967 }));
968
969 let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
970 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
971
972 assert!(matches!(
973 decoded,
974 Evt::SessionUpdated(payload)
975 if payload.model.id == "claude-sonnet-4-6"
976 && payload.provider.id == "anthropic"
977 && payload.provider.base_url == "https://api.anthropic.test/v1"
978 && payload.session_id == session_id
979 && payload.cwd == std::path::Path::new("/tmp/session-updated")
980 ));
981 }
982
983 #[test]
984 fn provider_spec_ignores_the_dropped_model_list() {
985 let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
988 "id": "anthropic",
989 "display_name": "Anthropic",
990 "base_url": "https://api.anthropic.test/v1",
991 "preferred_models": [{ "id": "claude-sonnet-4-6" }],
992 }))
993 .unwrap();
994
995 assert_eq!(spec.id, "anthropic");
996 assert_eq!(spec.display_name, "Anthropic");
997 assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
998
999 let encoded = serde_json::to_value(&spec).unwrap();
1001 assert!(encoded.get("preferred_models").is_none());
1002 }
1003
1004 #[test]
1005 fn context_report_serde_roundtrip() {
1006 let breakdown = super::ContextBreakdown {
1007 system_prompt_tokens: 1200,
1008 system_tools_tokens: 3400,
1009 mcp_tools_tokens: 0,
1010 memory_tokens: 800,
1011 skills_tokens: 150,
1012 messages_tokens: 42_000,
1013 used_tokens: 47_550,
1014 limit_tokens: Some(200_000),
1015 compact_buffer_tokens: 20_000,
1016 };
1017 let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1018 assert_eq!(
1019 json,
1020 serde_json::json!({
1021 "ContextReport": {
1022 "system_prompt_tokens": 1200,
1023 "system_tools_tokens": 3400,
1024 "mcp_tools_tokens": 0,
1025 "memory_tokens": 800,
1026 "skills_tokens": 150,
1027 "messages_tokens": 42000,
1028 "used_tokens": 47550,
1029 "limit_tokens": 200000,
1030 "compact_buffer_tokens": 20000
1031 }
1032 })
1033 );
1034 let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1035 assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1036
1037 let sparse: super::ContextBreakdown =
1039 serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1040 assert_eq!(sparse.used_tokens, 10);
1041 assert_eq!(sparse.limit_tokens, None);
1042
1043 let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1044 assert_eq!(op, serde_json::json!("ContextReport"));
1045 assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1046 }
1047
1048 #[test]
1049 fn usage_adds_cache_fields_without_overflowing() {
1050 let mut usage = Usage {
1051 input_tokens: 10,
1052 output_tokens: 20,
1053 cache_read_tokens: Some(3),
1054 cache_creation_tokens: None,
1055 };
1056 usage += Usage {
1057 input_tokens: 5,
1058 output_tokens: 6,
1059 cache_read_tokens: Some(4),
1060 cache_creation_tokens: Some(8),
1061 };
1062
1063 assert_eq!(usage.input_tokens, 15);
1064 assert_eq!(usage.output_tokens, 26);
1065 assert_eq!(usage.total(), 41);
1066 assert_eq!(usage.cache_read_tokens, Some(7));
1067 assert_eq!(usage.cache_creation_tokens, Some(8));
1068 }
1069}