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
23pub fn op_msg(op: Op) -> OpMsg {
25 OpMsg { op, id: Id::op() }
26}
27
28pub fn event_msg(event: Evt, parent: Option<Id>) -> EventMsg {
31 EventMsg { timestamp: Utc::now(), id: Id::evt(), event, parent }
32}
33
34#[allow(clippy::large_enum_variant)]
35#[derive(Debug, Clone, Deserialize, Serialize)]
36pub enum Op {
37 StartSession(SessionRequest),
42 UpdateSession(SessionUpdate),
43 Interrupt,
44 UserInput(String),
45 ShellInput(String),
46 Steer(String),
47 ApprovalResponse {
48 turn_id: Id,
49 responses: Vec<ToolDecision>,
50 },
51 SlashCommand {
52 name: String,
53 args: String,
54 },
55 ResumeSession {
61 session_id: Id,
62 #[serde(default)]
63 unattended: bool,
64 },
65 RegisterLocalProvider {
66 port: u16,
67 model: Option<ModelSpec>,
68 },
69 RestoreLocalProvider,
70 Compact {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 instructions: Option<String>,
76 },
77 ContextReport,
80 Goal(GoalCommand),
85 AmbientPhrase {
90 draft: String,
91 req_id: u64,
92 },
93 AmbientSuggestion {
99 recent_user: String,
100 recent_agent: String,
101 req_id: u64,
102 },
103 Shutdown,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108pub enum GoalCommand {
109 Set(String),
111 Clear,
113 Status,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
120pub enum AmbientKind {
121 ThinkingPhrase,
123 PromptSuggestion,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub enum Evt {
129 SessionStart(Box<SessionInfo>),
130 SessionUpdated(Box<SessionInfo>),
131 ExtensionRefreshed(Box<ExtensionRefreshed>),
132 SessionEnd {
135 session_id: Id,
136 reason: SessionEndReason,
137 usage: Usage,
138 },
139 UserInput(String),
140 ShellOutput {
141 command: String,
142 stdout: String,
143 stderr: String,
144 exit_code: Option<i32>,
145 },
146 AgentMessage(String),
147 Thinking(String),
148 MessageDelta(String),
149 ThinkingDelta(String),
150 Info(String),
151 InfoBlockStart {
160 id: String,
161 header: String,
162 #[serde(default)]
163 loading: bool,
164 },
165 InfoBlockAppend {
168 id: String,
169 detail: String,
170 },
171 Error(String),
172 ToolStart(ToolUse),
173 ToolUpdate(ToolUpdate),
174 ToolEnd(ToolEnd),
175 CompactStart,
176 CompactEnd {
181 #[serde(default)]
182 summary: Option<String>,
183 },
184 TurnStart {
185 turn_id: Id,
186 },
187 TurnPause {
188 turn_id: Id,
189 reason: TurnPauseReason,
190 },
191 TurnResume {
195 turn_id: Id,
196 },
197 TurnEnd {
198 turn_id: Id,
199 status: TurnEndStatus,
200 #[serde(default)]
202 steps: usize,
203 },
204 UsageUpdate {
205 usage: Usage,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
210 context: Option<ContextWindow>,
211 },
212 ContextReport(ContextBreakdown),
215 Ambient {
220 kind: AmbientKind,
221 req_id: u64,
222 text: String,
223 },
224 Goodbye,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub enum TurnPauseReason {
229 Approval { tools: Vec<ToolUse>, message: String },
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233pub enum SessionEndReason {
234 Replaced,
236 Shutdown,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub enum TurnEndStatus {
242 Completed,
243 Interrupted {
244 #[serde(skip_serializing_if = "Option::is_none")]
245 reason: Option<String>,
246 },
247 Error {
248 #[serde(default, skip_serializing_if = "Option::is_none")]
254 kind: Option<String>,
255 headline: String,
258 details: Vec<String>,
262 },
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct ToolUpdate {
267 pub tool_use_id: String,
268 pub seq: u64,
269 pub message: String,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273pub enum ToolEndStatus {
274 Completed,
275 Cancelled,
276 Denied,
277 Failed,
278}
279
280impl ToolEndStatus {
281 pub fn is_error(self) -> bool {
285 !matches!(self, ToolEndStatus::Completed)
286 }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct ToolEnd {
291 pub tool_use_id: String,
292 #[serde(default)]
294 pub tool_name: String,
295 pub status: ToolEndStatus,
296 pub result_json: serde_json::Value,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300pub enum ReviewDecision {
301 Accept,
302 Deny,
303 AcceptForSession,
304 AcceptAlways,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct ToolDecision {
312 pub tool_use_id: String,
313 pub decision: ReviewDecision,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub message: Option<String>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct SkillMetadata {
321 pub name: String,
322 pub description: Option<String>,
323 pub scope: Scope,
324 pub argument_hint: Option<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct SubagentMetadata {
329 pub name: String,
330 pub description: String,
331 pub scope: Scope,
332}
333
334#[derive(Debug, Clone, Default, Serialize, Deserialize)]
343pub struct ProviderSpec {
344 #[serde(alias = "name")]
345 pub id: String,
346 pub display_name: String,
347 pub base_url: String,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct SessionInfo {
355 pub model: ModelSpec,
356 pub provider: ProviderSpec,
357 pub session_id: Id,
358 pub cwd: PathBuf,
359 pub permission_mode: PermissionMode,
360 #[serde(default)]
362 pub skills: Vec<SkillMetadata>,
363 #[serde(default)]
365 pub subagents: Vec<SubagentMetadata>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub title: Option<String>,
370}
371
372#[derive(Debug, Clone, Default, Serialize, Deserialize)]
376pub struct SessionUpdate {
377 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub model: Option<ModelSpec>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub permission_mode: Option<PermissionMode>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub title: Option<String>,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct ExtensionRefreshed {
397 pub session_id: Id,
398 pub skills: Vec<SkillMetadata>,
399 pub subagents: Vec<SubagentMetadata>,
400 #[serde(default)]
401 pub mcp_servers: Vec<McpServerInfo>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct McpServerInfo {
406 pub name: String,
407 pub command: String,
408 pub args: Vec<String>,
409 pub tools: Vec<McpToolInfo>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct McpToolInfo {
414 pub name: String,
415 pub qualified_name: String,
416 pub description: String,
417 pub parameters: Vec<McpToolParam>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct McpToolParam {
422 pub name: String,
423 pub param_type: String,
424 pub required: bool,
425 pub description: String,
426}
427
428#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
435pub struct SessionRequest {
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub model: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub provider: Option<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub permission_mode: Option<PermissionMode>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub system_prompt: Option<String>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub append_system_prompt: Option<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub tools: Option<Vec<String>>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub include_tools: Option<Vec<String>>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub exclude_tools: Option<Vec<String>>,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub cwd: Option<PathBuf>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub effort: Option<Effort>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub enable_auto_memory: Option<bool>,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub short_prompt: Option<bool>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub no_skills: Option<bool>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub include_skills: Option<Vec<String>>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub exclude_skills: Option<Vec<String>>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub save_session: Option<bool>,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub unattended: Option<bool>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub title: Option<String>,
486}
487
488impl SessionRequest {
489 pub fn patched(self, patch: SessionRequest) -> SessionRequest {
496 let SessionRequest {
497 model,
498 provider,
499 permission_mode,
500 system_prompt,
501 append_system_prompt,
502 tools,
503 include_tools,
504 exclude_tools,
505 cwd,
506 effort,
507 enable_auto_memory,
508 short_prompt,
509 no_skills,
510 include_skills,
511 exclude_skills,
512 save_session,
513 unattended,
514 title,
515 } = patch;
516 SessionRequest {
517 model: model.or(self.model),
518 provider: provider.or(self.provider),
519 permission_mode: permission_mode.or(self.permission_mode),
520 system_prompt: system_prompt.or(self.system_prompt),
521 append_system_prompt: append_system_prompt.or(self.append_system_prompt),
522 tools: tools.or(self.tools),
523 include_tools: include_tools.or(self.include_tools),
524 exclude_tools: exclude_tools.or(self.exclude_tools),
525 cwd: cwd.or(self.cwd),
526 effort: effort.or(self.effort),
527 enable_auto_memory: enable_auto_memory.or(self.enable_auto_memory),
528 short_prompt: short_prompt.or(self.short_prompt),
529 no_skills: no_skills.or(self.no_skills),
530 include_skills: include_skills.or(self.include_skills),
531 exclude_skills: exclude_skills.or(self.exclude_skills),
532 save_session: save_session.or(self.save_session),
533 unattended: unattended.or(self.unattended),
534 title: title.or(self.title),
535 }
536 }
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
540pub struct MalformedToolArgs {
541 pub raw: String,
543 pub error: String,
545}
546
547pub const MISSING_TOOL_NAME: &str = "missing_function_name";
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
552pub struct ToolUse {
553 pub id: String,
554 pub name: String,
555 pub args: serde_json::Value,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub malformed_args: Option<MalformedToolArgs>,
563 #[serde(skip_serializing_if = "Option::is_none")]
564 pub signature: Option<String>,
565}
566
567impl ToolUse {
568 pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
570 Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
571 }
572}
573
574#[derive(Debug, Clone, Default, Serialize, Deserialize)]
575pub struct ModelSpec {
576 #[serde(alias = "name")]
577 pub id: String,
578 #[serde(skip_serializing_if = "Option::is_none")]
579 pub display_name: Option<String>,
580 #[serde(skip_serializing_if = "Option::is_none")]
581 pub description: Option<String>,
582 #[serde(skip_serializing_if = "Option::is_none")]
583 pub temperature: Option<f32>,
584 #[serde(skip_serializing_if = "Option::is_none")]
585 pub top_p: Option<f32>,
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub top_k: Option<u32>,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 pub max_tokens: Option<u32>,
590 #[serde(skip_serializing_if = "Option::is_none")]
591 pub stop_sequences: Option<Vec<String>>,
592 #[serde(skip_serializing_if = "Option::is_none")]
593 pub context_limit: Option<u32>,
594 #[serde(skip_serializing_if = "Option::is_none")]
595 pub effort: Option<Effort>,
596 #[serde(skip_serializing_if = "Option::is_none")]
600 pub supported_efforts: Option<Vec<Effort>>,
601 #[serde(skip_serializing_if = "Option::is_none")]
602 pub support_vision: Option<bool>,
603 #[serde(skip_serializing_if = "Option::is_none")]
604 pub weight_class: Option<WeightClass>,
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
615#[serde(rename_all = "lowercase")]
616pub enum Effort {
617 Min,
618 Low,
619 Medium,
620 High,
621 XHigh,
622 Max,
623}
624
625impl Effort {
626 pub const ALL: [Effort; 6] =
628 [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
629
630 pub fn as_str(self) -> &'static str {
632 match self {
633 Effort::Min => "min",
634 Effort::Low => "low",
635 Effort::Medium => "medium",
636 Effort::High => "high",
637 Effort::XHigh => "xhigh",
638 Effort::Max => "max",
639 }
640 }
641}
642
643impl std::fmt::Display for Effort {
644 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645 f.write_str(self.as_str())
646 }
647}
648
649impl std::str::FromStr for Effort {
650 type Err = String;
651
652 fn from_str(s: &str) -> Result<Self, Self::Err> {
653 Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
654 format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
655 })
656 }
657}
658
659#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
666#[serde(rename_all = "lowercase")]
667pub enum WeightClass {
668 Feather,
670 Middle,
672 Heavy,
674}
675
676impl<'de> Deserialize<'de> for WeightClass {
677 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
678 where
679 D: Deserializer<'de>,
680 {
681 let value = String::deserialize(deserializer)?;
682 match value.to_ascii_lowercase().as_str() {
683 "feather" => Ok(Self::Feather),
684 "middle" => Ok(Self::Middle),
685 "heavy" => Ok(Self::Heavy),
686 _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
687 }
688 }
689}
690
691#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
708#[serde(default)]
709pub struct Usage {
710 pub input_tokens: u32,
712 pub output_tokens: u32,
714 #[serde(skip_serializing_if = "Option::is_none")]
716 pub cache_read_tokens: Option<u32>,
717 #[serde(skip_serializing_if = "Option::is_none")]
719 pub cache_creation_tokens: Option<u32>,
720}
721
722#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
726pub struct ContextWindow {
727 pub used_tokens: u32,
730 pub limit_tokens: u32,
732}
733
734#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
742#[serde(default)]
743pub struct ContextBreakdown {
744 pub system_prompt_tokens: u32,
746 pub system_tools_tokens: u32,
748 pub mcp_tools_tokens: u32,
750 pub memory_tokens: u32,
752 pub skills_tokens: u32,
754 pub messages_tokens: u32,
756 pub used_tokens: u32,
758 pub limit_tokens: Option<u32>,
761 pub compact_buffer_tokens: u32,
764}
765
766impl Usage {
767 pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
768 Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
769 }
770
771 pub fn total(&self) -> u32 {
773 self.input_tokens.saturating_add(self.output_tokens)
774 }
775}
776
777impl std::ops::Add<Usage> for Usage {
778 type Output = Usage;
779
780 fn add(self, other: Usage) -> Usage {
781 Usage {
782 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
783 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
784 cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
785 cache_creation_tokens: add_optional_u32(
786 self.cache_creation_tokens,
787 other.cache_creation_tokens,
788 ),
789 }
790 }
791}
792
793fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
794 match (a, b) {
795 (None, None) => None,
796 _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
797 }
798}
799
800impl std::ops::AddAssign<Usage> for Usage {
801 fn add_assign(&mut self, other: Usage) {
802 *self = *self + other;
803 }
804}
805
806#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
807#[serde(rename_all = "snake_case")]
808pub enum PermissionMode {
809 #[default]
811 Strict,
812 Auto,
815 Yolo,
817}
818
819#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
820#[serde(rename_all = "kebab-case")]
821pub enum Scope {
822 Project,
823 User,
824 System,
825}
826
827#[cfg(test)]
828mod tests {
829 use super::{
830 Effort, Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
831 ReviewDecision, SessionInfo, SessionRequest, SessionUpdate, ToolDecision, ToolEnd,
832 ToolEndStatus, ToolUse, Usage, WeightClass, event_msg, op_msg,
833 };
834 use std::path::PathBuf;
835
836 #[test]
837 fn op_msg_assigns_runtime_id() {
838 let msg = op_msg(Op::Interrupt);
839
840 assert!(msg.id.to_string().starts_with("op_"));
841 assert!(matches!(msg.op, Op::Interrupt));
842 }
843
844 #[test]
845 fn event_msg_assigns_runtime_metadata() {
846 let event = event_msg(Evt::Info("hello".to_string()), None);
847
848 assert!(event.id.to_string().starts_with("evt_"));
849 assert!(matches!(event.event, Evt::Info(message) if message == "hello"));
850 assert!(event.parent.is_none());
851 }
852
853 fn model_spec(name: &str) -> ModelSpec {
854 ModelSpec {
855 id: name.to_string(),
856 display_name: None,
857 description: None,
858 temperature: None,
859 top_p: None,
860 top_k: None,
861 max_tokens: None,
862 stop_sequences: None,
863 context_limit: None,
864 effort: None,
865 supported_efforts: None,
866 support_vision: None,
867 weight_class: None,
868 }
869 }
870
871 #[test]
872 fn tool_use_without_malformed_args_remains_backward_compatible() {
873 let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
874 "id": "call-1",
875 "name": "Read",
876 "args": { "file_path": "README.md" }
877 }))
878 .unwrap();
879
880 assert!(tool_use.malformed_args.is_none());
881 let encoded = serde_json::to_value(tool_use).unwrap();
882 assert!(encoded.get("malformed_args").is_none());
883 }
884
885 #[test]
886 fn tool_end_without_name_remains_backward_compatible() {
887 let legacy = serde_json::json!({
888 "tool_use_id": "call-1",
889 "status": "Denied",
890 "result_json": "Tool call denied by policy and was not executed."
891 });
892 let mut end: ToolEnd = serde_json::from_value(legacy.clone()).unwrap();
893 assert!(end.tool_name.is_empty());
894 assert_eq!(end.status, ToolEndStatus::Denied);
895
896 end.tool_name = "Bash".to_string();
897 let mut expected = legacy;
898 expected["tool_name"] = serde_json::json!("Bash");
899 assert_eq!(serde_json::to_value(end).unwrap(), expected);
900 }
901
902 #[test]
903 fn turn_end_error_kind_is_optional_on_the_wire() {
904 let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
906 "Error": { "headline": "authentication error", "details": [] }
907 }))
908 .unwrap();
909 let super::TurnEndStatus::Error { kind, .. } = &old else {
910 panic!("expected Error variant");
911 };
912 assert!(kind.is_none());
913
914 let json = serde_json::to_value(&old).unwrap();
916 assert!(json["Error"].get("kind").is_none());
917
918 let with = super::TurnEndStatus::Error {
920 kind: Some("oauth".to_string()),
921 headline: "OAuth sign-in required".to_string(),
922 details: vec![],
923 };
924 let json = serde_json::to_value(&with).unwrap();
925 assert_eq!(json["Error"]["kind"], "oauth");
926 }
927
928 #[test]
929 fn turn_end_steps_default_for_older_events() {
930 let event = super::Evt::TurnEnd {
931 turn_id: super::Id::new("turn"),
932 status: super::TurnEndStatus::Completed,
933 steps: 3,
934 };
935 let mut json = serde_json::to_value(&event).unwrap();
936 json["TurnEnd"].as_object_mut().unwrap().remove("steps");
937 let old: super::Evt = serde_json::from_value(json).unwrap();
938
939 assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
940
941 let json = serde_json::to_value(event).unwrap();
942 assert_eq!(json["TurnEnd"]["steps"], 3);
943 }
944
945 #[test]
946 fn effort_serializes_as_lowercase_tokens() {
947 let mut spec = model_spec("m");
948 spec.effort = Some(super::Effort::XHigh);
949 let json = serde_json::to_value(&spec).unwrap();
950 assert_eq!(json["effort"], "xhigh");
951
952 let json_none = serde_json::to_value(model_spec("m")).unwrap();
954 assert!(json_none.get("effort").is_none());
955
956 for level in super::Effort::ALL {
958 let parsed: ModelSpec =
959 serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
960 .unwrap();
961 assert_eq!(parsed.effort, Some(level), "round-trip {level}");
962 }
963 }
964
965 #[test]
966 fn effort_orders_min_lowest_to_max_highest() {
967 let mut sorted = super::Effort::ALL;
968 sorted.sort();
969 assert_eq!(sorted, super::Effort::ALL);
970 assert!(super::Effort::Min < super::Effort::Low);
971 assert!(super::Effort::XHigh < super::Effort::Max);
972 }
973
974 #[test]
975 fn session_overrides_effort_round_trips() {
976 let parsed: super::SessionRequest =
977 serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
978 assert_eq!(parsed.effort, Some(super::Effort::Max));
979
980 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
981 assert_eq!(parsed.effort, None);
982 }
983
984 #[test]
985 fn short_prompt_round_trips_and_defaults_to_unset() {
986 let parsed: super::SessionRequest =
987 serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
988 assert_eq!(parsed.short_prompt, Some(true));
989
990 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
991 assert_eq!(parsed.short_prompt, None);
992 }
993
994 #[test]
995 fn skill_filters_are_optional_on_the_wire_and_preserve_empty_overrides() {
996 let absent: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
997 assert_eq!(absent.include_skills, None);
998 assert_eq!(absent.exclude_skills, None);
999 let encoded = serde_json::to_value(absent).unwrap();
1000 assert!(encoded.get("include_skills").is_none());
1001 assert!(encoded.get("exclude_skills").is_none());
1002
1003 let json = serde_json::json!({"include_skills": ["Review"], "exclude_skills": []});
1004 let parsed: SessionRequest = serde_json::from_value(json.clone()).unwrap();
1005 assert_eq!(parsed.include_skills, Some(vec!["Review".to_string()]));
1006 assert_eq!(parsed.exclude_skills, Some(Vec::new()));
1007 assert_eq!(serde_json::to_value(parsed).unwrap(), json);
1008 }
1009
1010 fn pinned_request() -> SessionRequest {
1011 SessionRequest {
1012 model: Some("base-model".to_string()),
1013 provider: Some("anthropic".to_string()),
1014 permission_mode: Some(PermissionMode::Strict),
1015 system_prompt: Some("base prompt".to_string()),
1016 cwd: Some(std::path::PathBuf::from("/base")),
1017 effort: Some(Effort::Medium),
1018 enable_auto_memory: Some(true),
1019 short_prompt: Some(true),
1020 no_skills: Some(true),
1021 include_skills: Some(vec!["review".to_string()]),
1022 exclude_skills: Some(vec!["noisy".to_string()]),
1023 save_session: Some(true),
1024 unattended: Some(true),
1025 title: Some("pinned title".to_string()),
1026 ..Default::default()
1027 }
1028 }
1029
1030 #[test]
1031 fn patched_with_an_empty_patch_keeps_every_field() {
1032 assert_eq!(pinned_request().patched(SessionRequest::default()), pinned_request());
1033 }
1034
1035 #[test]
1036 fn patched_overwrites_only_the_patch_set_fields() {
1037 let patched = pinned_request().patched(SessionRequest {
1038 model: Some("new-model".to_string()),
1039 permission_mode: Some(PermissionMode::Yolo),
1040 enable_auto_memory: Some(false),
1041 short_prompt: Some(false),
1042 include_skills: Some(Vec::new()),
1043 ..Default::default()
1044 });
1045 assert_eq!(patched.model.as_deref(), Some("new-model"));
1047 assert_eq!(patched.permission_mode, Some(PermissionMode::Yolo));
1048 assert_eq!(patched.enable_auto_memory, Some(false));
1049 assert_eq!(patched.short_prompt, Some(false));
1050 assert_eq!(patched.include_skills, Some(Vec::new()));
1051 assert_eq!(patched.provider.as_deref(), Some("anthropic"));
1053 assert_eq!(patched.system_prompt.as_deref(), Some("base prompt"));
1054 assert_eq!(patched.effort, Some(Effort::Medium));
1055 assert_eq!(patched.save_session, Some(true));
1056 assert_eq!(patched.unattended, Some(true));
1057 assert_eq!(patched.title.as_deref(), Some("pinned title"));
1058 assert_eq!(patched.exclude_skills, Some(vec!["noisy".to_string()]));
1059 }
1060
1061 #[test]
1062 fn unattended_is_optional_on_the_wire() {
1063 let request: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1066 assert_eq!(request.unattended, None);
1067
1068 let op = Op::ResumeSession { session_id: Id::new("ses"), unattended: true };
1069 let mut json = serde_json::to_value(&op).unwrap();
1070 json["ResumeSession"].as_object_mut().unwrap().remove("unattended");
1071 let decoded: Op = serde_json::from_value(json).unwrap();
1072 assert!(matches!(decoded, Op::ResumeSession { unattended: false, .. }));
1073 }
1074
1075 #[test]
1076 fn session_initialized_title_is_optional_on_the_wire() {
1077 let mut payload = SessionInfo {
1078 model: model_spec("m"),
1079 provider: provider_spec("p"),
1080 session_id: Id::new("ses"),
1081 cwd: PathBuf::from("/tmp"),
1082 permission_mode: PermissionMode::default(),
1083 skills: vec![],
1084 subagents: vec![],
1085 title: None,
1086 };
1087
1088 let json = serde_json::to_value(&payload).unwrap();
1089 assert!(json.get("title").is_none(), "an unset title is omitted: {json}");
1090 let decoded: SessionInfo = serde_json::from_value(json).unwrap();
1091 assert_eq!(decoded.title, None);
1092
1093 payload.title = Some("fix".to_string());
1094 let json = serde_json::to_value(&payload).unwrap();
1095 assert_eq!(json["title"], "fix");
1096 }
1097
1098 #[test]
1099 fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
1100 let mut spec = model_spec("m");
1101 spec.weight_class = Some(WeightClass::Heavy);
1102 let json = serde_json::to_value(&spec).unwrap();
1103 assert_eq!(json["weight_class"], "heavy");
1104
1105 let json_none = serde_json::to_value(model_spec("m")).unwrap();
1107 assert!(json_none.get("weight_class").is_none());
1108
1109 let parsed: ModelSpec =
1111 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
1112 .unwrap();
1113 assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
1114 }
1115
1116 #[test]
1117 fn weight_class_deserializes_case_insensitively() {
1118 for (value, expected) in [
1119 ("Feather", WeightClass::Feather),
1120 ("MIDDLE", WeightClass::Middle),
1121 ("hEaVy", WeightClass::Heavy),
1122 ] {
1123 let parsed: ModelSpec =
1124 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
1125 .unwrap();
1126 assert_eq!(parsed.weight_class, Some(expected));
1127 }
1128 }
1129
1130 #[test]
1131 fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
1132 assert!(WeightClass::Feather < WeightClass::Middle);
1133 assert!(WeightClass::Middle < WeightClass::Heavy);
1134 }
1135
1136 fn provider_spec(name: &str) -> ProviderSpec {
1137 ProviderSpec {
1138 id: name.to_string(),
1139 display_name: name.to_string(),
1140 base_url: format!("https://api.{name}.test/v1"),
1141 }
1142 }
1143
1144 #[test]
1145 fn compact_events_serde_roundtrip() {
1146 let compact_start =
1147 serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
1148 let compact_end =
1149 serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
1150 .expect("serialize CompactEnd");
1151
1152 assert_eq!(compact_start, "\"CompactStart\"");
1153 assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
1154
1155 assert!(matches!(
1156 serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
1157 Evt::CompactStart
1158 ));
1159 assert!(matches!(
1160 serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
1161 Evt::CompactEnd { summary: Some(s) } if s == "the summary"
1162 ));
1163 assert!(matches!(
1164 serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
1165 .expect("deserialize CompactEnd without summary"),
1166 Evt::CompactEnd { summary: None }
1167 ));
1168 }
1169
1170 #[test]
1171 fn compact_op_serde_roundtrip() {
1172 let plain = serde_json::to_string(&Op::Compact { instructions: None })
1173 .expect("serialize bare Compact");
1174 assert_eq!(plain, r#"{"Compact":{}}"#);
1175 assert!(matches!(
1176 serde_json::from_str::<Op>(&plain).expect("deserialize bare Compact"),
1177 Op::Compact { instructions: None }
1178 ));
1179
1180 let steered =
1181 serde_json::to_string(&Op::Compact { instructions: Some("keep dates".to_string()) })
1182 .expect("serialize steered Compact");
1183 assert_eq!(steered, r#"{"Compact":{"instructions":"keep dates"}}"#);
1184 assert!(matches!(
1185 serde_json::from_str::<Op>(&steered).expect("deserialize steered Compact"),
1186 Op::Compact { instructions: Some(text) } if text == "keep dates"
1187 ));
1188 }
1189
1190 #[test]
1191 fn session_end_and_turn_resume_serde_roundtrip() {
1192 let session_id = Id::new("ses");
1193 let end = Evt::SessionEnd {
1194 session_id,
1195 reason: super::SessionEndReason::Shutdown,
1196 usage: Usage::new(10, 5),
1197 };
1198 let json = serde_json::to_string(&end).expect("serialize SessionEnd");
1199 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
1200 assert!(matches!(
1201 decoded,
1202 Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
1203 if id == session_id && usage.total() == 15
1204 ));
1205
1206 let turn_id = Id::new("op");
1207 let resume = Evt::TurnResume { turn_id };
1208 let json = serde_json::to_string(&resume).expect("serialize TurnResume");
1209 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
1210 assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
1211 }
1212
1213 #[test]
1214 fn extension_refreshed_serde_roundtrip() {
1215 let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
1216 session_id: Id::new("ses"),
1217 skills: Vec::new(),
1218 subagents: Vec::new(),
1219 mcp_servers: Vec::new(),
1220 }));
1221
1222 let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
1223 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
1224
1225 assert!(matches!(
1226 decoded,
1227 Evt::ExtensionRefreshed(payload)
1228 if payload.skills.is_empty() && payload.subagents.is_empty()
1229 ));
1230 }
1231
1232 #[test]
1233 fn session_update_op_serde_roundtrip() {
1234 let op = Op::UpdateSession(SessionUpdate {
1235 model: Some(ModelSpec {
1236 temperature: Some(0.2),
1237 effort: Some(super::Effort::High),
1238 ..model_spec("gpt-5.4")
1239 }),
1240 permission_mode: Some(PermissionMode::Yolo),
1241 title: Some("renamed".to_string()),
1242 });
1243
1244 let json = serde_json::to_string(&op).expect("serialize UpdateSession");
1245 let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
1246
1247 assert!(matches!(
1248 decoded,
1249 Op::UpdateSession(SessionUpdate {
1250 model: Some(model),
1251 permission_mode: Some(PermissionMode::Yolo),
1252 title: Some(title),
1253 })
1254 if model.id == "gpt-5.4"
1255 && model.temperature == Some(0.2)
1256 && model.effort == Some(super::Effort::High)
1257 && title == "renamed"
1258 ));
1259 }
1260
1261 #[test]
1262 fn approval_response_uses_named_tool_decisions() {
1263 let turn_id = Id::new("turn");
1264 let op = Op::ApprovalResponse {
1265 turn_id,
1266 responses: vec![ToolDecision {
1267 tool_use_id: "call-1".to_string(),
1268 decision: ReviewDecision::Deny,
1269 message: Some("use the read-only endpoint".to_string()),
1270 }],
1271 };
1272
1273 let json = serde_json::to_value(&op).expect("serialize ApprovalResponse");
1274 assert_eq!(
1275 json["ApprovalResponse"]["responses"],
1276 serde_json::json!([{
1277 "tool_use_id": "call-1",
1278 "decision": "Deny",
1279 "message": "use the read-only endpoint"
1280 }])
1281 );
1282
1283 let decoded = serde_json::from_value::<Op>(json).expect("deserialize ApprovalResponse");
1284 assert!(matches!(
1285 decoded,
1286 Op::ApprovalResponse { turn_id: id, responses }
1287 if id == turn_id
1288 && responses == vec![ToolDecision {
1289 tool_use_id: "call-1".to_string(),
1290 decision: ReviewDecision::Deny,
1291 message: Some("use the read-only endpoint".to_string()),
1292 }]
1293 ));
1294 }
1295
1296 #[test]
1297 fn session_updated_event_serde_roundtrip() {
1298 let session_id = Id::new("ses");
1299 let event = Evt::SessionUpdated(Box::new(SessionInfo {
1300 model: model_spec("claude-sonnet-4-6"),
1301 provider: provider_spec("anthropic"),
1302 session_id,
1303 cwd: PathBuf::from("/tmp/session-updated"),
1304 permission_mode: PermissionMode::default(),
1305 skills: vec![],
1306 subagents: vec![],
1307 title: None,
1308 }));
1309
1310 let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
1311 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
1312
1313 assert!(matches!(
1314 decoded,
1315 Evt::SessionUpdated(payload)
1316 if payload.model.id == "claude-sonnet-4-6"
1317 && payload.provider.id == "anthropic"
1318 && payload.provider.base_url == "https://api.anthropic.test/v1"
1319 && payload.session_id == session_id
1320 && payload.cwd == std::path::Path::new("/tmp/session-updated")
1321 ));
1322 }
1323
1324 #[test]
1325 fn provider_spec_ignores_the_dropped_model_list() {
1326 let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
1329 "id": "anthropic",
1330 "display_name": "Anthropic",
1331 "base_url": "https://api.anthropic.test/v1",
1332 "preferred_models": [{ "id": "claude-sonnet-4-6" }],
1333 }))
1334 .unwrap();
1335
1336 assert_eq!(spec.id, "anthropic");
1337 assert_eq!(spec.display_name, "Anthropic");
1338 assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
1339
1340 let encoded = serde_json::to_value(&spec).unwrap();
1342 assert!(encoded.get("preferred_models").is_none());
1343 }
1344
1345 #[test]
1346 fn context_report_serde_roundtrip() {
1347 let breakdown = super::ContextBreakdown {
1348 system_prompt_tokens: 1200,
1349 system_tools_tokens: 3400,
1350 mcp_tools_tokens: 0,
1351 memory_tokens: 800,
1352 skills_tokens: 150,
1353 messages_tokens: 42_000,
1354 used_tokens: 47_550,
1355 limit_tokens: Some(200_000),
1356 compact_buffer_tokens: 20_000,
1357 };
1358 let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1359 assert_eq!(
1360 json,
1361 serde_json::json!({
1362 "ContextReport": {
1363 "system_prompt_tokens": 1200,
1364 "system_tools_tokens": 3400,
1365 "mcp_tools_tokens": 0,
1366 "memory_tokens": 800,
1367 "skills_tokens": 150,
1368 "messages_tokens": 42000,
1369 "used_tokens": 47550,
1370 "limit_tokens": 200000,
1371 "compact_buffer_tokens": 20000
1372 }
1373 })
1374 );
1375 let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1376 assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1377
1378 let sparse: super::ContextBreakdown =
1380 serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1381 assert_eq!(sparse.used_tokens, 10);
1382 assert_eq!(sparse.limit_tokens, None);
1383
1384 let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1385 assert_eq!(op, serde_json::json!("ContextReport"));
1386 assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1387 }
1388
1389 #[test]
1390 fn usage_adds_cache_fields_without_overflowing() {
1391 let mut usage = Usage {
1392 input_tokens: 10,
1393 output_tokens: 20,
1394 cache_read_tokens: Some(3),
1395 cache_creation_tokens: None,
1396 };
1397 usage += Usage {
1398 input_tokens: 5,
1399 output_tokens: 6,
1400 cache_read_tokens: Some(4),
1401 cache_creation_tokens: Some(8),
1402 };
1403
1404 assert_eq!(usage.input_tokens, 15);
1405 assert_eq!(usage.output_tokens, 26);
1406 assert_eq!(usage.total(), 41);
1407 assert_eq!(usage.cache_read_tokens, Some(7));
1408 assert_eq!(usage.cache_creation_tokens, Some(8));
1409 }
1410}