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 QuestionResponse {
57 turn_id: Id,
58 tool_use_id: String,
59 reply: QuestionReply,
60 },
61 SlashCommand {
62 name: String,
63 args: String,
64 },
65 ResumeSession {
71 session_id: Id,
72 #[serde(default)]
73 unattended: bool,
74 },
75 RegisterLocalProvider {
76 port: u16,
77 model: Option<ModelSpec>,
78 },
79 RestoreLocalProvider,
80 Compact {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 instructions: Option<String>,
86 },
87 ContextReport,
90 Goal(GoalCommand),
95 AmbientPhrase {
100 draft: String,
101 req_id: u64,
102 },
103 AmbientSuggestion {
109 recent_user: String,
110 recent_agent: String,
111 req_id: u64,
112 },
113 Shutdown,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118pub enum GoalCommand {
119 Set(String),
121 Clear,
123 Status,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
130pub enum AmbientKind {
131 ThinkingPhrase,
133 PromptSuggestion,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub enum Evt {
139 SessionStart(Box<SessionInfo>),
140 SessionUpdated(Box<SessionInfo>),
141 ExtensionRefreshed(Box<ExtensionRefreshed>),
142 SessionEnd {
145 session_id: Id,
146 reason: SessionEndReason,
147 usage: Usage,
148 },
149 UserInput(String),
150 ShellOutput {
151 command: String,
152 stdout: String,
153 stderr: String,
154 exit_code: Option<i32>,
155 },
156 AgentMessage(String),
157 Thinking(String),
158 MessageDelta(String),
159 ThinkingDelta(String),
160 Info(String),
161 InfoBlockStart {
170 id: String,
171 header: String,
172 #[serde(default)]
173 loading: bool,
174 },
175 InfoBlockAppend {
178 id: String,
179 detail: String,
180 },
181 Error(String),
182 ToolStart(ToolUse),
183 ToolUpdate(ToolUpdate),
184 ToolEnd(ToolEnd),
185 CompactStart,
186 CompactEnd {
191 #[serde(default)]
192 summary: Option<String>,
193 },
194 TurnStart {
195 turn_id: Id,
196 },
197 TurnPause {
198 turn_id: Id,
199 reason: TurnPauseReason,
200 },
201 TurnResume {
205 turn_id: Id,
206 },
207 TurnEnd {
208 turn_id: Id,
209 status: TurnEndStatus,
210 #[serde(default)]
212 steps: usize,
213 },
214 UsageUpdate {
215 usage: Usage,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
220 context: Option<ContextWindow>,
221 },
222 ContextReport(ContextBreakdown),
225 Ambient {
230 kind: AmbientKind,
231 req_id: u64,
232 text: String,
233 },
234 Goodbye,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub enum TurnPauseReason {
239 Approval {
240 tools: Vec<ToolUse>,
241 message: String,
242 },
243 Question {
252 tool_use_id: String,
253 questions: Vec<QuestionSpec>,
254 },
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
259pub struct QuestionSpec {
260 pub header: String,
262 pub question: String,
264 #[serde(default)]
266 pub multi_select: bool,
267 pub options: Vec<QuestionOption>,
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct QuestionOption {
275 pub label: String,
276 pub description: String,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub preview: Option<String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct QuestionAnswer {
287 pub selected: Vec<String>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub note: Option<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub enum QuestionReply {
299 Answered(Vec<QuestionAnswer>),
302 Dismissed,
304 Discuss {
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 message: Option<String>,
310 },
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314pub enum SessionEndReason {
315 Replaced,
317 Shutdown,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub enum TurnEndStatus {
323 Completed,
324 Interrupted {
325 #[serde(skip_serializing_if = "Option::is_none")]
326 reason: Option<String>,
327 },
328 Error {
329 #[serde(default, skip_serializing_if = "Option::is_none")]
335 kind: Option<String>,
336 headline: String,
339 details: Vec<String>,
343 },
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ToolUpdate {
348 pub tool_use_id: String,
349 pub seq: u64,
350 pub message: String,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
354pub enum ToolEndStatus {
355 Completed,
356 Cancelled,
357 Denied,
358 Failed,
359}
360
361impl ToolEndStatus {
362 pub fn is_error(self) -> bool {
366 !matches!(self, ToolEndStatus::Completed)
367 }
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct ToolEnd {
372 pub tool_use_id: String,
373 #[serde(default)]
375 pub tool_name: String,
376 pub status: ToolEndStatus,
377 pub result_json: serde_json::Value,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381pub enum ReviewDecision {
382 Accept,
383 Deny,
384 AcceptForSession,
385 AcceptAlways,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct ToolDecision {
393 pub tool_use_id: String,
394 pub decision: ReviewDecision,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub message: Option<String>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct SkillMetadata {
402 pub name: String,
403 pub description: Option<String>,
404 pub scope: Scope,
405 pub argument_hint: Option<String>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct SubagentMetadata {
410 pub name: String,
411 pub description: String,
412 pub scope: Scope,
413}
414
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
424pub struct ProviderSpec {
425 #[serde(alias = "name")]
426 pub id: String,
427 pub display_name: String,
428 pub base_url: String,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct SessionInfo {
436 pub model: ModelSpec,
437 pub provider: ProviderSpec,
438 pub session_id: Id,
439 pub cwd: PathBuf,
440 pub permission_mode: PermissionMode,
441 #[serde(default)]
443 pub skills: Vec<SkillMetadata>,
444 #[serde(default)]
446 pub subagents: Vec<SubagentMetadata>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub title: Option<String>,
451}
452
453#[derive(Debug, Clone, Default, Serialize, Deserialize)]
457pub struct SessionUpdate {
458 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub model: Option<ModelSpec>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub permission_mode: Option<PermissionMode>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub title: Option<String>,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct ExtensionRefreshed {
478 pub session_id: Id,
479 pub skills: Vec<SkillMetadata>,
480 pub subagents: Vec<SubagentMetadata>,
481 #[serde(default)]
482 pub mcp_servers: Vec<McpServerInfo>,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct McpServerInfo {
487 pub name: String,
488 pub command: String,
489 pub args: Vec<String>,
490 pub tools: Vec<McpToolInfo>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct McpToolInfo {
495 pub name: String,
496 pub qualified_name: String,
497 pub description: String,
498 pub parameters: Vec<McpToolParam>,
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize)]
502pub struct McpToolParam {
503 pub name: String,
504 pub param_type: String,
505 pub required: bool,
506 pub description: String,
507}
508
509#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
516pub struct SessionRequest {
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub model: Option<String>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub provider: Option<String>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub permission_mode: Option<PermissionMode>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub system_prompt: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub append_system_prompt: Option<String>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub tools: Option<Vec<String>>,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub include_tools: Option<Vec<String>>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub exclude_tools: Option<Vec<String>>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub cwd: Option<PathBuf>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub effort: Option<Effort>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub enable_auto_memory: Option<bool>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub short_prompt: Option<bool>,
544 #[serde(default, skip_serializing_if = "Option::is_none")]
547 pub no_skills: Option<bool>,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub include_skills: Option<Vec<String>>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub exclude_skills: Option<Vec<String>>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub save_session: Option<bool>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub unattended: Option<bool>,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
566 pub title: Option<String>,
567}
568
569impl SessionRequest {
570 pub fn patched(self, patch: SessionRequest) -> SessionRequest {
577 let SessionRequest {
578 model,
579 provider,
580 permission_mode,
581 system_prompt,
582 append_system_prompt,
583 tools,
584 include_tools,
585 exclude_tools,
586 cwd,
587 effort,
588 enable_auto_memory,
589 short_prompt,
590 no_skills,
591 include_skills,
592 exclude_skills,
593 save_session,
594 unattended,
595 title,
596 } = patch;
597 SessionRequest {
598 model: model.or(self.model),
599 provider: provider.or(self.provider),
600 permission_mode: permission_mode.or(self.permission_mode),
601 system_prompt: system_prompt.or(self.system_prompt),
602 append_system_prompt: append_system_prompt.or(self.append_system_prompt),
603 tools: tools.or(self.tools),
604 include_tools: include_tools.or(self.include_tools),
605 exclude_tools: exclude_tools.or(self.exclude_tools),
606 cwd: cwd.or(self.cwd),
607 effort: effort.or(self.effort),
608 enable_auto_memory: enable_auto_memory.or(self.enable_auto_memory),
609 short_prompt: short_prompt.or(self.short_prompt),
610 no_skills: no_skills.or(self.no_skills),
611 include_skills: include_skills.or(self.include_skills),
612 exclude_skills: exclude_skills.or(self.exclude_skills),
613 save_session: save_session.or(self.save_session),
614 unattended: unattended.or(self.unattended),
615 title: title.or(self.title),
616 }
617 }
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
621pub struct MalformedToolArgs {
622 pub raw: String,
624 pub error: String,
626}
627
628pub const MISSING_TOOL_NAME: &str = "missing_function_name";
631
632#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
633pub struct ToolUse {
634 pub id: String,
635 pub name: String,
636 pub args: serde_json::Value,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub malformed_args: Option<MalformedToolArgs>,
644 #[serde(skip_serializing_if = "Option::is_none")]
645 pub signature: Option<String>,
646}
647
648impl ToolUse {
649 pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
651 Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
652 }
653}
654
655#[derive(Debug, Clone, Default, Serialize, Deserialize)]
656pub struct ModelSpec {
657 #[serde(alias = "name")]
658 pub id: String,
659 #[serde(skip_serializing_if = "Option::is_none")]
660 pub display_name: Option<String>,
661 #[serde(skip_serializing_if = "Option::is_none")]
662 pub description: Option<String>,
663 #[serde(skip_serializing_if = "Option::is_none")]
664 pub temperature: Option<f32>,
665 #[serde(skip_serializing_if = "Option::is_none")]
666 pub top_p: Option<f32>,
667 #[serde(skip_serializing_if = "Option::is_none")]
668 pub top_k: Option<u32>,
669 #[serde(skip_serializing_if = "Option::is_none")]
670 pub max_tokens: Option<u32>,
671 #[serde(skip_serializing_if = "Option::is_none")]
672 pub stop_sequences: Option<Vec<String>>,
673 #[serde(skip_serializing_if = "Option::is_none")]
674 pub context_limit: Option<u32>,
675 #[serde(skip_serializing_if = "Option::is_none")]
676 pub effort: Option<Effort>,
677 #[serde(skip_serializing_if = "Option::is_none")]
681 pub supported_efforts: Option<Vec<Effort>>,
682 #[serde(skip_serializing_if = "Option::is_none")]
683 pub support_vision: Option<bool>,
684 #[serde(skip_serializing_if = "Option::is_none")]
685 pub weight_class: Option<WeightClass>,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
696#[serde(rename_all = "lowercase")]
697pub enum Effort {
698 Min,
699 Low,
700 Medium,
701 High,
702 XHigh,
703 Max,
704}
705
706impl Effort {
707 pub const ALL: [Effort; 6] =
709 [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
710
711 pub fn as_str(self) -> &'static str {
713 match self {
714 Effort::Min => "min",
715 Effort::Low => "low",
716 Effort::Medium => "medium",
717 Effort::High => "high",
718 Effort::XHigh => "xhigh",
719 Effort::Max => "max",
720 }
721 }
722}
723
724impl std::fmt::Display for Effort {
725 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726 f.write_str(self.as_str())
727 }
728}
729
730impl std::str::FromStr for Effort {
731 type Err = String;
732
733 fn from_str(s: &str) -> Result<Self, Self::Err> {
734 Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
735 format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
736 })
737 }
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
747#[serde(rename_all = "lowercase")]
748pub enum WeightClass {
749 Feather,
751 Middle,
753 Heavy,
755}
756
757impl<'de> Deserialize<'de> for WeightClass {
758 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
759 where
760 D: Deserializer<'de>,
761 {
762 let value = String::deserialize(deserializer)?;
763 match value.to_ascii_lowercase().as_str() {
764 "feather" => Ok(Self::Feather),
765 "middle" => Ok(Self::Middle),
766 "heavy" => Ok(Self::Heavy),
767 _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
768 }
769 }
770}
771
772#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
789#[serde(default)]
790pub struct Usage {
791 pub input_tokens: u32,
793 pub output_tokens: u32,
795 #[serde(skip_serializing_if = "Option::is_none")]
797 pub cache_read_tokens: Option<u32>,
798 #[serde(skip_serializing_if = "Option::is_none")]
800 pub cache_creation_tokens: Option<u32>,
801}
802
803#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
807pub struct ContextWindow {
808 pub used_tokens: u32,
811 pub limit_tokens: u32,
813}
814
815#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
823#[serde(default)]
824pub struct ContextBreakdown {
825 pub system_prompt_tokens: u32,
827 pub system_tools_tokens: u32,
829 pub mcp_tools_tokens: u32,
831 pub memory_tokens: u32,
833 pub skills_tokens: u32,
835 pub messages_tokens: u32,
837 pub used_tokens: u32,
839 pub limit_tokens: Option<u32>,
842 pub compact_buffer_tokens: u32,
845}
846
847impl Usage {
848 pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
849 Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
850 }
851
852 pub fn total(&self) -> u32 {
854 self.input_tokens.saturating_add(self.output_tokens)
855 }
856}
857
858impl std::ops::Add<Usage> for Usage {
859 type Output = Usage;
860
861 fn add(self, other: Usage) -> Usage {
862 Usage {
863 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
864 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
865 cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
866 cache_creation_tokens: add_optional_u32(
867 self.cache_creation_tokens,
868 other.cache_creation_tokens,
869 ),
870 }
871 }
872}
873
874fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
875 match (a, b) {
876 (None, None) => None,
877 _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
878 }
879}
880
881impl std::ops::AddAssign<Usage> for Usage {
882 fn add_assign(&mut self, other: Usage) {
883 *self = *self + other;
884 }
885}
886
887#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
888#[serde(rename_all = "snake_case")]
889pub enum PermissionMode {
890 #[default]
892 Strict,
893 Auto,
896 Yolo,
898}
899
900#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
901#[serde(rename_all = "kebab-case")]
902pub enum Scope {
903 Project,
904 User,
905 System,
906}
907
908#[cfg(test)]
909mod tests {
910 use super::{
911 Effort, Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
912 ReviewDecision, SessionInfo, SessionRequest, SessionUpdate, ToolDecision, ToolEnd,
913 ToolEndStatus, ToolUse, Usage, WeightClass, event_msg, op_msg,
914 };
915 use std::path::PathBuf;
916
917 #[test]
918 fn op_msg_assigns_runtime_id() {
919 let msg = op_msg(Op::Interrupt);
920
921 assert!(msg.id.to_string().starts_with("op_"));
922 assert!(matches!(msg.op, Op::Interrupt));
923 }
924
925 #[test]
926 fn event_msg_assigns_runtime_metadata() {
927 let event = event_msg(Evt::Info("hello".to_string()), None);
928
929 assert!(event.id.to_string().starts_with("evt_"));
930 assert!(matches!(event.event, Evt::Info(message) if message == "hello"));
931 assert!(event.parent.is_none());
932 }
933
934 fn model_spec(name: &str) -> ModelSpec {
935 ModelSpec {
936 id: name.to_string(),
937 display_name: None,
938 description: None,
939 temperature: None,
940 top_p: None,
941 top_k: None,
942 max_tokens: None,
943 stop_sequences: None,
944 context_limit: None,
945 effort: None,
946 supported_efforts: None,
947 support_vision: None,
948 weight_class: None,
949 }
950 }
951
952 #[test]
953 fn tool_use_without_malformed_args_remains_backward_compatible() {
954 let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
955 "id": "call-1",
956 "name": "Read",
957 "args": { "file_path": "README.md" }
958 }))
959 .unwrap();
960
961 assert!(tool_use.malformed_args.is_none());
962 let encoded = serde_json::to_value(tool_use).unwrap();
963 assert!(encoded.get("malformed_args").is_none());
964 }
965
966 #[test]
967 fn tool_end_without_name_remains_backward_compatible() {
968 let legacy = serde_json::json!({
969 "tool_use_id": "call-1",
970 "status": "Denied",
971 "result_json": "Tool call denied by policy and was not executed."
972 });
973 let mut end: ToolEnd = serde_json::from_value(legacy.clone()).unwrap();
974 assert!(end.tool_name.is_empty());
975 assert_eq!(end.status, ToolEndStatus::Denied);
976
977 end.tool_name = "Bash".to_string();
978 let mut expected = legacy;
979 expected["tool_name"] = serde_json::json!("Bash");
980 assert_eq!(serde_json::to_value(end).unwrap(), expected);
981 }
982
983 #[test]
984 fn turn_end_error_kind_is_optional_on_the_wire() {
985 let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
987 "Error": { "headline": "authentication error", "details": [] }
988 }))
989 .unwrap();
990 let super::TurnEndStatus::Error { kind, .. } = &old else {
991 panic!("expected Error variant");
992 };
993 assert!(kind.is_none());
994
995 let json = serde_json::to_value(&old).unwrap();
997 assert!(json["Error"].get("kind").is_none());
998
999 let with = super::TurnEndStatus::Error {
1001 kind: Some("oauth".to_string()),
1002 headline: "OAuth sign-in required".to_string(),
1003 details: vec![],
1004 };
1005 let json = serde_json::to_value(&with).unwrap();
1006 assert_eq!(json["Error"]["kind"], "oauth");
1007 }
1008
1009 #[test]
1010 fn turn_end_steps_default_for_older_events() {
1011 let event = super::Evt::TurnEnd {
1012 turn_id: super::Id::new("turn"),
1013 status: super::TurnEndStatus::Completed,
1014 steps: 3,
1015 };
1016 let mut json = serde_json::to_value(&event).unwrap();
1017 json["TurnEnd"].as_object_mut().unwrap().remove("steps");
1018 let old: super::Evt = serde_json::from_value(json).unwrap();
1019
1020 assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
1021
1022 let json = serde_json::to_value(event).unwrap();
1023 assert_eq!(json["TurnEnd"]["steps"], 3);
1024 }
1025
1026 #[test]
1027 fn effort_serializes_as_lowercase_tokens() {
1028 let mut spec = model_spec("m");
1029 spec.effort = Some(super::Effort::XHigh);
1030 let json = serde_json::to_value(&spec).unwrap();
1031 assert_eq!(json["effort"], "xhigh");
1032
1033 let json_none = serde_json::to_value(model_spec("m")).unwrap();
1035 assert!(json_none.get("effort").is_none());
1036
1037 for level in super::Effort::ALL {
1039 let parsed: ModelSpec =
1040 serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
1041 .unwrap();
1042 assert_eq!(parsed.effort, Some(level), "round-trip {level}");
1043 }
1044 }
1045
1046 #[test]
1047 fn effort_orders_min_lowest_to_max_highest() {
1048 let mut sorted = super::Effort::ALL;
1049 sorted.sort();
1050 assert_eq!(sorted, super::Effort::ALL);
1051 assert!(super::Effort::Min < super::Effort::Low);
1052 assert!(super::Effort::XHigh < super::Effort::Max);
1053 }
1054
1055 #[test]
1056 fn session_overrides_effort_round_trips() {
1057 let parsed: super::SessionRequest =
1058 serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
1059 assert_eq!(parsed.effort, Some(super::Effort::Max));
1060
1061 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1062 assert_eq!(parsed.effort, None);
1063 }
1064
1065 #[test]
1066 fn short_prompt_round_trips_and_defaults_to_unset() {
1067 let parsed: super::SessionRequest =
1068 serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
1069 assert_eq!(parsed.short_prompt, Some(true));
1070
1071 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1072 assert_eq!(parsed.short_prompt, None);
1073 }
1074
1075 #[test]
1076 fn skill_filters_are_optional_on_the_wire_and_preserve_empty_overrides() {
1077 let absent: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1078 assert_eq!(absent.include_skills, None);
1079 assert_eq!(absent.exclude_skills, None);
1080 let encoded = serde_json::to_value(absent).unwrap();
1081 assert!(encoded.get("include_skills").is_none());
1082 assert!(encoded.get("exclude_skills").is_none());
1083
1084 let json = serde_json::json!({"include_skills": ["Review"], "exclude_skills": []});
1085 let parsed: SessionRequest = serde_json::from_value(json.clone()).unwrap();
1086 assert_eq!(parsed.include_skills, Some(vec!["Review".to_string()]));
1087 assert_eq!(parsed.exclude_skills, Some(Vec::new()));
1088 assert_eq!(serde_json::to_value(parsed).unwrap(), json);
1089 }
1090
1091 fn pinned_request() -> SessionRequest {
1092 SessionRequest {
1093 model: Some("base-model".to_string()),
1094 provider: Some("anthropic".to_string()),
1095 permission_mode: Some(PermissionMode::Strict),
1096 system_prompt: Some("base prompt".to_string()),
1097 cwd: Some(std::path::PathBuf::from("/base")),
1098 effort: Some(Effort::Medium),
1099 enable_auto_memory: Some(true),
1100 short_prompt: Some(true),
1101 no_skills: Some(true),
1102 include_skills: Some(vec!["review".to_string()]),
1103 exclude_skills: Some(vec!["noisy".to_string()]),
1104 save_session: Some(true),
1105 unattended: Some(true),
1106 title: Some("pinned title".to_string()),
1107 ..Default::default()
1108 }
1109 }
1110
1111 #[test]
1112 fn patched_with_an_empty_patch_keeps_every_field() {
1113 assert_eq!(pinned_request().patched(SessionRequest::default()), pinned_request());
1114 }
1115
1116 #[test]
1117 fn patched_overwrites_only_the_patch_set_fields() {
1118 let patched = pinned_request().patched(SessionRequest {
1119 model: Some("new-model".to_string()),
1120 permission_mode: Some(PermissionMode::Yolo),
1121 enable_auto_memory: Some(false),
1122 short_prompt: Some(false),
1123 include_skills: Some(Vec::new()),
1124 ..Default::default()
1125 });
1126 assert_eq!(patched.model.as_deref(), Some("new-model"));
1128 assert_eq!(patched.permission_mode, Some(PermissionMode::Yolo));
1129 assert_eq!(patched.enable_auto_memory, Some(false));
1130 assert_eq!(patched.short_prompt, Some(false));
1131 assert_eq!(patched.include_skills, Some(Vec::new()));
1132 assert_eq!(patched.provider.as_deref(), Some("anthropic"));
1134 assert_eq!(patched.system_prompt.as_deref(), Some("base prompt"));
1135 assert_eq!(patched.effort, Some(Effort::Medium));
1136 assert_eq!(patched.save_session, Some(true));
1137 assert_eq!(patched.unattended, Some(true));
1138 assert_eq!(patched.title.as_deref(), Some("pinned title"));
1139 assert_eq!(patched.exclude_skills, Some(vec!["noisy".to_string()]));
1140 }
1141
1142 #[test]
1143 fn unattended_is_optional_on_the_wire() {
1144 let request: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1147 assert_eq!(request.unattended, None);
1148
1149 let op = Op::ResumeSession { session_id: Id::new("ses"), unattended: true };
1150 let mut json = serde_json::to_value(&op).unwrap();
1151 json["ResumeSession"].as_object_mut().unwrap().remove("unattended");
1152 let decoded: Op = serde_json::from_value(json).unwrap();
1153 assert!(matches!(decoded, Op::ResumeSession { unattended: false, .. }));
1154 }
1155
1156 #[test]
1157 fn session_initialized_title_is_optional_on_the_wire() {
1158 let mut payload = SessionInfo {
1159 model: model_spec("m"),
1160 provider: provider_spec("p"),
1161 session_id: Id::new("ses"),
1162 cwd: PathBuf::from("/tmp"),
1163 permission_mode: PermissionMode::default(),
1164 skills: vec![],
1165 subagents: vec![],
1166 title: None,
1167 };
1168
1169 let json = serde_json::to_value(&payload).unwrap();
1170 assert!(json.get("title").is_none(), "an unset title is omitted: {json}");
1171 let decoded: SessionInfo = serde_json::from_value(json).unwrap();
1172 assert_eq!(decoded.title, None);
1173
1174 payload.title = Some("fix".to_string());
1175 let json = serde_json::to_value(&payload).unwrap();
1176 assert_eq!(json["title"], "fix");
1177 }
1178
1179 #[test]
1180 fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
1181 let mut spec = model_spec("m");
1182 spec.weight_class = Some(WeightClass::Heavy);
1183 let json = serde_json::to_value(&spec).unwrap();
1184 assert_eq!(json["weight_class"], "heavy");
1185
1186 let json_none = serde_json::to_value(model_spec("m")).unwrap();
1188 assert!(json_none.get("weight_class").is_none());
1189
1190 let parsed: ModelSpec =
1192 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
1193 .unwrap();
1194 assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
1195 }
1196
1197 #[test]
1198 fn weight_class_deserializes_case_insensitively() {
1199 for (value, expected) in [
1200 ("Feather", WeightClass::Feather),
1201 ("MIDDLE", WeightClass::Middle),
1202 ("hEaVy", WeightClass::Heavy),
1203 ] {
1204 let parsed: ModelSpec =
1205 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
1206 .unwrap();
1207 assert_eq!(parsed.weight_class, Some(expected));
1208 }
1209 }
1210
1211 #[test]
1212 fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
1213 assert!(WeightClass::Feather < WeightClass::Middle);
1214 assert!(WeightClass::Middle < WeightClass::Heavy);
1215 }
1216
1217 fn provider_spec(name: &str) -> ProviderSpec {
1218 ProviderSpec {
1219 id: name.to_string(),
1220 display_name: name.to_string(),
1221 base_url: format!("https://api.{name}.test/v1"),
1222 }
1223 }
1224
1225 #[test]
1226 fn compact_events_serde_roundtrip() {
1227 let compact_start =
1228 serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
1229 let compact_end =
1230 serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
1231 .expect("serialize CompactEnd");
1232
1233 assert_eq!(compact_start, "\"CompactStart\"");
1234 assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
1235
1236 assert!(matches!(
1237 serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
1238 Evt::CompactStart
1239 ));
1240 assert!(matches!(
1241 serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
1242 Evt::CompactEnd { summary: Some(s) } if s == "the summary"
1243 ));
1244 assert!(matches!(
1245 serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
1246 .expect("deserialize CompactEnd without summary"),
1247 Evt::CompactEnd { summary: None }
1248 ));
1249 }
1250
1251 #[test]
1252 fn compact_op_serde_roundtrip() {
1253 let plain = serde_json::to_string(&Op::Compact { instructions: None })
1254 .expect("serialize bare Compact");
1255 assert_eq!(plain, r#"{"Compact":{}}"#);
1256 assert!(matches!(
1257 serde_json::from_str::<Op>(&plain).expect("deserialize bare Compact"),
1258 Op::Compact { instructions: None }
1259 ));
1260
1261 let steered =
1262 serde_json::to_string(&Op::Compact { instructions: Some("keep dates".to_string()) })
1263 .expect("serialize steered Compact");
1264 assert_eq!(steered, r#"{"Compact":{"instructions":"keep dates"}}"#);
1265 assert!(matches!(
1266 serde_json::from_str::<Op>(&steered).expect("deserialize steered Compact"),
1267 Op::Compact { instructions: Some(text) } if text == "keep dates"
1268 ));
1269 }
1270
1271 #[test]
1272 fn session_end_and_turn_resume_serde_roundtrip() {
1273 let session_id = Id::new("ses");
1274 let end = Evt::SessionEnd {
1275 session_id,
1276 reason: super::SessionEndReason::Shutdown,
1277 usage: Usage::new(10, 5),
1278 };
1279 let json = serde_json::to_string(&end).expect("serialize SessionEnd");
1280 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
1281 assert!(matches!(
1282 decoded,
1283 Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
1284 if id == session_id && usage.total() == 15
1285 ));
1286
1287 let turn_id = Id::new("op");
1288 let resume = Evt::TurnResume { turn_id };
1289 let json = serde_json::to_string(&resume).expect("serialize TurnResume");
1290 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
1291 assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
1292 }
1293
1294 #[test]
1295 fn extension_refreshed_serde_roundtrip() {
1296 let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
1297 session_id: Id::new("ses"),
1298 skills: Vec::new(),
1299 subagents: Vec::new(),
1300 mcp_servers: Vec::new(),
1301 }));
1302
1303 let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
1304 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
1305
1306 assert!(matches!(
1307 decoded,
1308 Evt::ExtensionRefreshed(payload)
1309 if payload.skills.is_empty() && payload.subagents.is_empty()
1310 ));
1311 }
1312
1313 #[test]
1314 fn session_update_op_serde_roundtrip() {
1315 let op = Op::UpdateSession(SessionUpdate {
1316 model: Some(ModelSpec {
1317 temperature: Some(0.2),
1318 effort: Some(super::Effort::High),
1319 ..model_spec("gpt-5.4")
1320 }),
1321 permission_mode: Some(PermissionMode::Yolo),
1322 title: Some("renamed".to_string()),
1323 });
1324
1325 let json = serde_json::to_string(&op).expect("serialize UpdateSession");
1326 let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
1327
1328 assert!(matches!(
1329 decoded,
1330 Op::UpdateSession(SessionUpdate {
1331 model: Some(model),
1332 permission_mode: Some(PermissionMode::Yolo),
1333 title: Some(title),
1334 })
1335 if model.id == "gpt-5.4"
1336 && model.temperature == Some(0.2)
1337 && model.effort == Some(super::Effort::High)
1338 && title == "renamed"
1339 ));
1340 }
1341
1342 #[test]
1343 fn approval_response_uses_named_tool_decisions() {
1344 let turn_id = Id::new("turn");
1345 let op = Op::ApprovalResponse {
1346 turn_id,
1347 responses: vec![ToolDecision {
1348 tool_use_id: "call-1".to_string(),
1349 decision: ReviewDecision::Deny,
1350 message: Some("use the read-only endpoint".to_string()),
1351 }],
1352 };
1353
1354 let json = serde_json::to_value(&op).expect("serialize ApprovalResponse");
1355 assert_eq!(
1356 json["ApprovalResponse"]["responses"],
1357 serde_json::json!([{
1358 "tool_use_id": "call-1",
1359 "decision": "Deny",
1360 "message": "use the read-only endpoint"
1361 }])
1362 );
1363
1364 let decoded = serde_json::from_value::<Op>(json).expect("deserialize ApprovalResponse");
1365 assert!(matches!(
1366 decoded,
1367 Op::ApprovalResponse { turn_id: id, responses }
1368 if id == turn_id
1369 && responses == vec![ToolDecision {
1370 tool_use_id: "call-1".to_string(),
1371 decision: ReviewDecision::Deny,
1372 message: Some("use the read-only endpoint".to_string()),
1373 }]
1374 ));
1375 }
1376
1377 #[test]
1378 fn session_updated_event_serde_roundtrip() {
1379 let session_id = Id::new("ses");
1380 let event = Evt::SessionUpdated(Box::new(SessionInfo {
1381 model: model_spec("claude-sonnet-4-6"),
1382 provider: provider_spec("anthropic"),
1383 session_id,
1384 cwd: PathBuf::from("/tmp/session-updated"),
1385 permission_mode: PermissionMode::default(),
1386 skills: vec![],
1387 subagents: vec![],
1388 title: None,
1389 }));
1390
1391 let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
1392 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
1393
1394 assert!(matches!(
1395 decoded,
1396 Evt::SessionUpdated(payload)
1397 if payload.model.id == "claude-sonnet-4-6"
1398 && payload.provider.id == "anthropic"
1399 && payload.provider.base_url == "https://api.anthropic.test/v1"
1400 && payload.session_id == session_id
1401 && payload.cwd == std::path::Path::new("/tmp/session-updated")
1402 ));
1403 }
1404
1405 #[test]
1406 fn provider_spec_ignores_the_dropped_model_list() {
1407 let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
1410 "id": "anthropic",
1411 "display_name": "Anthropic",
1412 "base_url": "https://api.anthropic.test/v1",
1413 "preferred_models": [{ "id": "claude-sonnet-4-6" }],
1414 }))
1415 .unwrap();
1416
1417 assert_eq!(spec.id, "anthropic");
1418 assert_eq!(spec.display_name, "Anthropic");
1419 assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
1420
1421 let encoded = serde_json::to_value(&spec).unwrap();
1423 assert!(encoded.get("preferred_models").is_none());
1424 }
1425
1426 #[test]
1427 fn context_report_serde_roundtrip() {
1428 let breakdown = super::ContextBreakdown {
1429 system_prompt_tokens: 1200,
1430 system_tools_tokens: 3400,
1431 mcp_tools_tokens: 0,
1432 memory_tokens: 800,
1433 skills_tokens: 150,
1434 messages_tokens: 42_000,
1435 used_tokens: 47_550,
1436 limit_tokens: Some(200_000),
1437 compact_buffer_tokens: 20_000,
1438 };
1439 let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1440 assert_eq!(
1441 json,
1442 serde_json::json!({
1443 "ContextReport": {
1444 "system_prompt_tokens": 1200,
1445 "system_tools_tokens": 3400,
1446 "mcp_tools_tokens": 0,
1447 "memory_tokens": 800,
1448 "skills_tokens": 150,
1449 "messages_tokens": 42000,
1450 "used_tokens": 47550,
1451 "limit_tokens": 200000,
1452 "compact_buffer_tokens": 20000
1453 }
1454 })
1455 );
1456 let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1457 assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1458
1459 let sparse: super::ContextBreakdown =
1461 serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1462 assert_eq!(sparse.used_tokens, 10);
1463 assert_eq!(sparse.limit_tokens, None);
1464
1465 let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1466 assert_eq!(op, serde_json::json!("ContextReport"));
1467 assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1468 }
1469
1470 #[test]
1471 fn question_pause_serde_roundtrip() {
1472 let turn_id = Id::new("op");
1473 let pause = Evt::TurnPause {
1474 turn_id,
1475 reason: super::TurnPauseReason::Question {
1476 tool_use_id: "toolu_1".to_string(),
1477 questions: vec![super::QuestionSpec {
1478 header: "Auth method".to_string(),
1479 question: "Which auth method should we use?".to_string(),
1480 multi_select: false,
1481 options: vec![
1482 super::QuestionOption {
1483 label: "JWT (Recommended)".to_string(),
1484 description: "Stateless tokens".to_string(),
1485 preview: None,
1486 },
1487 super::QuestionOption {
1488 label: "Sessions".to_string(),
1489 description: "Server-side sessions".to_string(),
1490 preview: Some("fn login() {}".to_string()),
1491 },
1492 ],
1493 }],
1494 },
1495 };
1496
1497 let json = serde_json::to_value(&pause).expect("serialize question pause");
1498 let spec = &json["TurnPause"]["reason"]["Question"]["questions"][0];
1500 assert!(spec["options"][0].get("preview").is_none());
1501 assert_eq!(spec["options"][1]["preview"], "fn login() {}");
1502
1503 let decoded: Evt = serde_json::from_value(json).expect("deserialize question pause");
1504 let Evt::TurnPause {
1505 reason: super::TurnPauseReason::Question { tool_use_id, questions },
1506 ..
1507 } = decoded
1508 else {
1509 panic!("expected Question pause");
1510 };
1511 assert_eq!(tool_use_id, "toolu_1");
1512 assert_eq!(questions.len(), 1);
1513 assert!(!questions[0].multi_select);
1514
1515 let sparse: super::QuestionSpec = serde_json::from_value(serde_json::json!({
1517 "header": "Scope",
1518 "question": "How broad?",
1519 "options": [],
1520 }))
1521 .unwrap();
1522 assert!(!sparse.multi_select);
1523 }
1524
1525 #[test]
1526 fn question_response_serde_roundtrip() {
1527 let turn_id = Id::new("op");
1528 let op = Op::QuestionResponse {
1529 turn_id,
1530 tool_use_id: "toolu_1".to_string(),
1531 reply: super::QuestionReply::Answered(vec![super::QuestionAnswer {
1532 selected: vec!["JWT".to_string(), "Sessions".to_string()],
1533 note: None,
1534 }]),
1535 };
1536 let json = serde_json::to_string(&op).expect("serialize QuestionResponse");
1537 let decoded: Op = serde_json::from_str(&json).expect("deserialize QuestionResponse");
1538 assert!(matches!(
1539 decoded,
1540 Op::QuestionResponse { tool_use_id, reply: super::QuestionReply::Answered(answers), .. }
1541 if tool_use_id == "toolu_1" && answers[0].selected.len() == 2
1542 ));
1543
1544 for (reply, expected) in [
1547 (super::QuestionReply::Dismissed, serde_json::json!("Dismissed")),
1548 (super::QuestionReply::Discuss { message: None }, serde_json::json!({ "Discuss": {} })),
1549 (
1550 super::QuestionReply::Discuss { message: Some("later".to_string()) },
1551 serde_json::json!({ "Discuss": { "message": "later" } }),
1552 ),
1553 ] {
1554 let json = serde_json::to_value(&reply).expect("serialize reply");
1555 assert_eq!(json, expected);
1556 let decoded: super::QuestionReply =
1557 serde_json::from_value(json).expect("deserialize reply");
1558 assert_eq!(decoded, reply);
1559 }
1560 }
1561
1562 #[test]
1563 fn usage_adds_cache_fields_without_overflowing() {
1564 let mut usage = Usage {
1565 input_tokens: 10,
1566 output_tokens: 20,
1567 cache_read_tokens: Some(3),
1568 cache_creation_tokens: None,
1569 };
1570 usage += Usage {
1571 input_tokens: 5,
1572 output_tokens: 6,
1573 cache_read_tokens: Some(4),
1574 cache_creation_tokens: Some(8),
1575 };
1576
1577 assert_eq!(usage.input_tokens, 15);
1578 assert_eq!(usage.output_tokens, 26);
1579 assert_eq!(usage.total(), 41);
1580 assert_eq!(usage.cache_read_tokens, Some(7));
1581 assert_eq!(usage.cache_creation_tokens, Some(8));
1582 }
1583}