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 {
72 session_id: Id,
73 #[serde(default)]
74 unattended: bool,
75 },
76 RegisterLocalProvider {
77 port: u16,
78 model: Option<ModelSpec>,
79 },
80 RestoreLocalProvider,
81 Compact {
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 instructions: Option<String>,
87 },
88 ContextReport,
91 RewindSession {
103 to: Id,
104 },
105 ForkSession {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 at: Option<Id>,
114 },
115 Goal(GoalCommand),
120 AmbientPhrase {
125 draft: String,
126 req_id: u64,
127 },
128 AmbientSuggestion {
134 recent_user: String,
135 recent_agent: String,
136 req_id: u64,
137 },
138 Shutdown,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub enum GoalCommand {
144 Set(String),
146 Clear,
148 Status,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
155pub enum AmbientKind {
156 ThinkingPhrase,
158 PromptSuggestion,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub enum Evt {
164 SessionStart(Box<SessionInfo>),
165 SessionUpdated(Box<SessionInfo>),
166 ExtensionRefreshed(Box<ExtensionRefreshed>),
167 SessionEnd {
170 session_id: Id,
171 reason: SessionEndReason,
172 usage: Usage,
173 },
174 UserInput(String),
175 ShellOutput {
176 command: String,
177 stdout: String,
178 stderr: String,
179 exit_code: Option<i32>,
180 },
181 AgentMessage(String),
182 Thinking(String),
183 MessageDelta(String),
184 ThinkingDelta(String),
185 Info(String),
186 InfoBlockStart {
195 id: String,
196 header: String,
197 #[serde(default)]
198 loading: bool,
199 },
200 InfoBlockAppend {
203 id: String,
204 detail: String,
205 },
206 Error(String),
207 ToolStart(ToolUse),
208 ToolUpdate(ToolUpdate),
209 ToolEnd(ToolEnd),
210 TaskEnd {
215 tool_use_id: String,
216 exit_code: Option<i32>,
217 },
218 CompactStart,
219 CompactEnd {
224 #[serde(default)]
225 summary: Option<String>,
226 },
227 TurnStart {
228 turn_id: Id,
229 },
230 TurnPause {
231 turn_id: Id,
232 reason: TurnPauseReason,
233 },
234 TurnResume {
238 turn_id: Id,
239 },
240 TurnEnd {
241 turn_id: Id,
242 status: TurnEndStatus,
243 #[serde(default)]
245 steps: usize,
246 },
247 UsageUpdate {
248 usage: Usage,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
253 context: Option<ContextWindow>,
254 },
255 ContextReport(ContextBreakdown),
258 SessionRewound {
262 to: Id,
263 },
264 SessionForked {
269 forked_from: Id,
270 session_id: Id,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 at: Option<Id>,
273 },
274 Ambient {
279 kind: AmbientKind,
280 req_id: u64,
281 text: String,
282 },
283 Goodbye,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub enum TurnPauseReason {
288 Approval {
289 tools: Vec<ToolUse>,
290 message: String,
291 },
292 Question {
301 tool_use_id: String,
302 questions: Vec<QuestionSpec>,
303 },
304}
305
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
308pub struct QuestionSpec {
309 pub header: String,
311 pub question: String,
313 #[serde(default)]
315 pub multi_select: bool,
316 pub options: Vec<QuestionOption>,
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323pub struct QuestionOption {
324 pub label: String,
325 pub description: String,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub preview: Option<String>,
331}
332
333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
335pub struct QuestionAnswer {
336 pub selected: Vec<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub note: Option<String>,
343}
344
345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347pub enum QuestionReply {
348 Answered(Vec<QuestionAnswer>),
351 Dismissed,
353 Discuss {
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 message: Option<String>,
359 },
360}
361
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
363pub enum SessionEndReason {
364 Replaced,
366 Shutdown,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub enum TurnEndStatus {
372 Completed,
373 Interrupted {
374 #[serde(skip_serializing_if = "Option::is_none")]
375 reason: Option<String>,
376 },
377 Error {
378 #[serde(default, skip_serializing_if = "Option::is_none")]
384 kind: Option<String>,
385 headline: String,
388 details: Vec<String>,
392 },
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct ToolUpdate {
397 pub tool_use_id: String,
398 pub seq: u64,
399 pub message: String,
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
403pub enum ToolEndStatus {
404 Completed,
405 Cancelled,
406 Denied,
407 Failed,
408}
409
410impl ToolEndStatus {
411 pub fn is_error(self) -> bool {
415 !matches!(self, ToolEndStatus::Completed)
416 }
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct ToolEnd {
421 pub tool_use_id: String,
422 #[serde(default)]
424 pub tool_name: String,
425 pub status: ToolEndStatus,
426 pub result_json: serde_json::Value,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub enum ReviewDecision {
431 Accept,
432 Deny,
433 AcceptForSession,
434 AcceptAlways,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
441pub struct ToolDecision {
442 pub tool_use_id: String,
443 pub decision: ReviewDecision,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub message: Option<String>,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct SkillMetadata {
451 pub name: String,
452 pub description: Option<String>,
453 pub scope: Scope,
454 pub argument_hint: Option<String>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct SubagentMetadata {
459 pub name: String,
460 pub description: String,
461 pub scope: Scope,
462}
463
464#[derive(Debug, Clone, Default, Serialize, Deserialize)]
473pub struct ProviderSpec {
474 #[serde(alias = "name")]
475 pub id: String,
476 pub display_name: String,
477 pub base_url: String,
478}
479
480#[derive(Debug, Clone, Default, Serialize, Deserialize)]
484pub struct SessionInfo {
485 pub model: ModelSpec,
486 pub provider: ProviderSpec,
487 pub session_id: Id,
488 pub cwd: PathBuf,
489 pub permission_mode: PermissionMode,
490 #[serde(default)]
492 pub skills: Vec<SkillMetadata>,
493 #[serde(default)]
495 pub subagents: Vec<SubagentMetadata>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub title: Option<String>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub forked_from: Option<Id>,
504}
505
506#[derive(Debug, Clone, Default, Serialize, Deserialize)]
510pub struct SessionUpdate {
511 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub provider: Option<String>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub model: Option<ModelSpec>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub permission_mode: Option<PermissionMode>,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub title: Option<String>,
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
540pub struct ExtensionRefreshed {
541 pub session_id: Id,
542 pub skills: Vec<SkillMetadata>,
543 pub subagents: Vec<SubagentMetadata>,
544 #[serde(default)]
545 pub mcp_servers: Vec<McpServerInfo>,
546}
547
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct McpServerInfo {
550 pub name: String,
551 pub command: String,
552 pub args: Vec<String>,
553 pub tools: Vec<McpToolInfo>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
557pub struct McpToolInfo {
558 pub name: String,
559 pub qualified_name: String,
560 pub description: String,
561 pub parameters: Vec<McpToolParam>,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct McpToolParam {
566 pub name: String,
567 pub param_type: String,
568 pub required: bool,
569 pub description: String,
570}
571
572#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
579pub struct SessionRequest {
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub model: Option<String>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub provider: Option<String>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub permission_mode: Option<PermissionMode>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub system_prompt: Option<String>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub append_system_prompt: Option<String>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub tools: Option<Vec<String>>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub include_tools: Option<Vec<String>>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub exclude_tools: Option<Vec<String>>,
599 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub cwd: Option<PathBuf>,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub effort: Option<Effort>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub enable_auto_memory: Option<bool>,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub short_prompt: Option<bool>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub no_skills: Option<bool>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub include_skills: Option<Vec<String>>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub exclude_skills: Option<Vec<String>>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub save_session: Option<bool>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub unattended: Option<bool>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub title: Option<String>,
630}
631
632impl SessionRequest {
633 pub fn patched(self, patch: SessionRequest) -> SessionRequest {
640 let SessionRequest {
641 model,
642 provider,
643 permission_mode,
644 system_prompt,
645 append_system_prompt,
646 tools,
647 include_tools,
648 exclude_tools,
649 cwd,
650 effort,
651 enable_auto_memory,
652 short_prompt,
653 no_skills,
654 include_skills,
655 exclude_skills,
656 save_session,
657 unattended,
658 title,
659 } = patch;
660 SessionRequest {
661 model: model.or(self.model),
662 provider: provider.or(self.provider),
663 permission_mode: permission_mode.or(self.permission_mode),
664 system_prompt: system_prompt.or(self.system_prompt),
665 append_system_prompt: append_system_prompt.or(self.append_system_prompt),
666 tools: tools.or(self.tools),
667 include_tools: include_tools.or(self.include_tools),
668 exclude_tools: exclude_tools.or(self.exclude_tools),
669 cwd: cwd.or(self.cwd),
670 effort: effort.or(self.effort),
671 enable_auto_memory: enable_auto_memory.or(self.enable_auto_memory),
672 short_prompt: short_prompt.or(self.short_prompt),
673 no_skills: no_skills.or(self.no_skills),
674 include_skills: include_skills.or(self.include_skills),
675 exclude_skills: exclude_skills.or(self.exclude_skills),
676 save_session: save_session.or(self.save_session),
677 unattended: unattended.or(self.unattended),
678 title: title.or(self.title),
679 }
680 }
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
684pub struct MalformedToolArgs {
685 pub raw: String,
687 pub error: String,
689}
690
691pub const MISSING_TOOL_NAME: &str = "missing_function_name";
694
695#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
696pub struct ToolUse {
697 pub id: String,
698 pub name: String,
699 pub args: serde_json::Value,
700 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub malformed_args: Option<MalformedToolArgs>,
707 #[serde(skip_serializing_if = "Option::is_none")]
708 pub signature: Option<String>,
709}
710
711impl ToolUse {
712 pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
714 Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
715 }
716}
717
718#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
719pub struct ModelSpec {
720 #[serde(alias = "name")]
721 pub id: String,
722 #[serde(skip_serializing_if = "Option::is_none")]
723 pub display_name: Option<String>,
724 #[serde(skip_serializing_if = "Option::is_none")]
725 pub description: Option<String>,
726 #[serde(skip_serializing_if = "Option::is_none")]
727 pub temperature: Option<f32>,
728 #[serde(skip_serializing_if = "Option::is_none")]
729 pub top_p: Option<f32>,
730 #[serde(skip_serializing_if = "Option::is_none")]
731 pub top_k: Option<u32>,
732 #[serde(skip_serializing_if = "Option::is_none")]
733 pub max_tokens: Option<u32>,
734 #[serde(skip_serializing_if = "Option::is_none")]
735 pub stop_sequences: Option<Vec<String>>,
736 #[serde(skip_serializing_if = "Option::is_none")]
737 pub context_limit: Option<u32>,
738 #[serde(skip_serializing_if = "Option::is_none")]
739 pub effort: Option<Effort>,
740 #[serde(skip_serializing_if = "Option::is_none")]
744 pub supported_efforts: Option<Vec<Effort>>,
745 #[serde(skip_serializing_if = "Option::is_none")]
746 pub support_vision: Option<bool>,
747 #[serde(skip_serializing_if = "Option::is_none")]
748 pub weight_class: Option<WeightClass>,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
759#[serde(rename_all = "lowercase")]
760pub enum Effort {
761 Min,
762 Low,
763 Medium,
764 High,
765 XHigh,
766 Max,
767}
768
769impl Effort {
770 pub const ALL: [Effort; 6] =
772 [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
773
774 pub fn as_str(self) -> &'static str {
776 match self {
777 Effort::Min => "min",
778 Effort::Low => "low",
779 Effort::Medium => "medium",
780 Effort::High => "high",
781 Effort::XHigh => "xhigh",
782 Effort::Max => "max",
783 }
784 }
785}
786
787impl std::fmt::Display for Effort {
788 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789 f.write_str(self.as_str())
790 }
791}
792
793impl std::str::FromStr for Effort {
794 type Err = String;
795
796 fn from_str(s: &str) -> Result<Self, Self::Err> {
797 Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
798 format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
799 })
800 }
801}
802
803#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
810#[serde(rename_all = "lowercase")]
811pub enum WeightClass {
812 Feather,
814 Middle,
816 Heavy,
818}
819
820impl<'de> Deserialize<'de> for WeightClass {
821 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
822 where
823 D: Deserializer<'de>,
824 {
825 let value = String::deserialize(deserializer)?;
826 match value.to_ascii_lowercase().as_str() {
827 "feather" => Ok(Self::Feather),
828 "middle" => Ok(Self::Middle),
829 "heavy" => Ok(Self::Heavy),
830 _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
831 }
832 }
833}
834
835#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
852#[serde(default)]
853pub struct Usage {
854 pub input_tokens: u32,
856 pub output_tokens: u32,
858 #[serde(skip_serializing_if = "Option::is_none")]
860 pub cache_read_tokens: Option<u32>,
861 #[serde(skip_serializing_if = "Option::is_none")]
863 pub cache_creation_tokens: Option<u32>,
864}
865
866#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
870pub struct ContextWindow {
871 pub used_tokens: u32,
874 pub limit_tokens: u32,
876}
877
878#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
886#[serde(default)]
887pub struct ContextBreakdown {
888 pub system_prompt_tokens: u32,
890 pub system_tools_tokens: u32,
892 pub mcp_tools_tokens: u32,
894 pub memory_tokens: u32,
896 pub skills_tokens: u32,
898 pub messages_tokens: u32,
900 pub used_tokens: u32,
902 pub limit_tokens: Option<u32>,
905 pub compact_buffer_tokens: u32,
908}
909
910impl Usage {
911 pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
912 Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
913 }
914
915 pub fn total(&self) -> u32 {
917 self.input_tokens.saturating_add(self.output_tokens)
918 }
919}
920
921impl std::ops::Add<Usage> for Usage {
922 type Output = Usage;
923
924 fn add(self, other: Usage) -> Usage {
925 Usage {
926 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
927 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
928 cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
929 cache_creation_tokens: add_optional_u32(
930 self.cache_creation_tokens,
931 other.cache_creation_tokens,
932 ),
933 }
934 }
935}
936
937fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
938 match (a, b) {
939 (None, None) => None,
940 _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
941 }
942}
943
944impl std::ops::AddAssign<Usage> for Usage {
945 fn add_assign(&mut self, other: Usage) {
946 *self = *self + other;
947 }
948}
949
950#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
951#[serde(rename_all = "snake_case")]
952pub enum PermissionMode {
953 #[default]
955 Strict,
956 Auto,
959 Yolo,
961}
962
963#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
964#[serde(rename_all = "kebab-case")]
965pub enum Scope {
966 Project,
967 User,
968 System,
969}
970
971#[cfg(test)]
972mod tests {
973 use super::{
974 Effort, Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
975 ReviewDecision, SessionInfo, SessionRequest, SessionUpdate, ToolDecision, ToolEnd,
976 ToolEndStatus, ToolUse, Usage, WeightClass, event_msg, op_msg,
977 };
978 use std::path::PathBuf;
979
980 #[test]
981 fn op_msg_assigns_runtime_id() {
982 let msg = op_msg(Op::Interrupt);
983
984 assert!(msg.id.to_string().starts_with("op_"));
985 assert!(matches!(msg.op, Op::Interrupt));
986 }
987
988 #[test]
989 fn event_msg_assigns_runtime_metadata() {
990 let event = event_msg(Evt::Info("hello".to_string()), None);
991
992 assert!(event.id.to_string().starts_with("evt_"));
993 assert!(matches!(event.event, Evt::Info(message) if message == "hello"));
994 assert!(event.parent.is_none());
995 }
996
997 fn model_spec(name: &str) -> ModelSpec {
998 ModelSpec {
999 id: name.to_string(),
1000 display_name: None,
1001 description: None,
1002 temperature: None,
1003 top_p: None,
1004 top_k: None,
1005 max_tokens: None,
1006 stop_sequences: None,
1007 context_limit: None,
1008 effort: None,
1009 supported_efforts: None,
1010 support_vision: None,
1011 weight_class: None,
1012 }
1013 }
1014
1015 #[test]
1016 fn tool_use_without_malformed_args_remains_backward_compatible() {
1017 let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
1018 "id": "call-1",
1019 "name": "Read",
1020 "args": { "file_path": "README.md" }
1021 }))
1022 .unwrap();
1023
1024 assert!(tool_use.malformed_args.is_none());
1025 let encoded = serde_json::to_value(tool_use).unwrap();
1026 assert!(encoded.get("malformed_args").is_none());
1027 }
1028
1029 #[test]
1030 fn tool_end_without_name_remains_backward_compatible() {
1031 let legacy = serde_json::json!({
1032 "tool_use_id": "call-1",
1033 "status": "Denied",
1034 "result_json": "Tool call denied by policy and was not executed."
1035 });
1036 let mut end: ToolEnd = serde_json::from_value(legacy.clone()).unwrap();
1037 assert!(end.tool_name.is_empty());
1038 assert_eq!(end.status, ToolEndStatus::Denied);
1039
1040 end.tool_name = "Bash".to_string();
1041 let mut expected = legacy;
1042 expected["tool_name"] = serde_json::json!("Bash");
1043 assert_eq!(serde_json::to_value(end).unwrap(), expected);
1044 }
1045
1046 #[test]
1047 fn task_end_round_trips_with_observed_or_unobserved_exit() {
1048 for exit_code in [Some(2), None] {
1049 let json = serde_json::json!({
1050 "TaskEnd": { "tool_use_id": "bash-1", "exit_code": exit_code }
1051 });
1052 let event: super::Evt = serde_json::from_value(json.clone()).unwrap();
1053 assert!(matches!(&event, super::Evt::TaskEnd { tool_use_id, exit_code: code }
1054 if tool_use_id == "bash-1" && *code == exit_code));
1055 assert_eq!(serde_json::to_value(event).unwrap(), json);
1056 }
1057 }
1058
1059 #[test]
1060 fn turn_end_error_kind_is_optional_on_the_wire() {
1061 let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
1063 "Error": { "headline": "authentication error", "details": [] }
1064 }))
1065 .unwrap();
1066 let super::TurnEndStatus::Error { kind, .. } = &old else {
1067 panic!("expected Error variant");
1068 };
1069 assert!(kind.is_none());
1070
1071 let json = serde_json::to_value(&old).unwrap();
1073 assert!(json["Error"].get("kind").is_none());
1074
1075 let with = super::TurnEndStatus::Error {
1077 kind: Some("oauth".to_string()),
1078 headline: "OAuth sign-in required".to_string(),
1079 details: vec![],
1080 };
1081 let json = serde_json::to_value(&with).unwrap();
1082 assert_eq!(json["Error"]["kind"], "oauth");
1083 }
1084
1085 #[test]
1086 fn turn_end_steps_default_for_older_events() {
1087 let event = super::Evt::TurnEnd {
1088 turn_id: super::Id::new("turn"),
1089 status: super::TurnEndStatus::Completed,
1090 steps: 3,
1091 };
1092 let mut json = serde_json::to_value(&event).unwrap();
1093 json["TurnEnd"].as_object_mut().unwrap().remove("steps");
1094 let old: super::Evt = serde_json::from_value(json).unwrap();
1095
1096 assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
1097
1098 let json = serde_json::to_value(event).unwrap();
1099 assert_eq!(json["TurnEnd"]["steps"], 3);
1100 }
1101
1102 #[test]
1103 fn effort_serializes_as_lowercase_tokens() {
1104 let mut spec = model_spec("m");
1105 spec.effort = Some(super::Effort::XHigh);
1106 let json = serde_json::to_value(&spec).unwrap();
1107 assert_eq!(json["effort"], "xhigh");
1108
1109 let json_none = serde_json::to_value(model_spec("m")).unwrap();
1111 assert!(json_none.get("effort").is_none());
1112
1113 for level in super::Effort::ALL {
1115 let parsed: ModelSpec =
1116 serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
1117 .unwrap();
1118 assert_eq!(parsed.effort, Some(level), "round-trip {level}");
1119 }
1120 }
1121
1122 #[test]
1123 fn effort_orders_min_lowest_to_max_highest() {
1124 let mut sorted = super::Effort::ALL;
1125 sorted.sort();
1126 assert_eq!(sorted, super::Effort::ALL);
1127 assert!(super::Effort::Min < super::Effort::Low);
1128 assert!(super::Effort::XHigh < super::Effort::Max);
1129 }
1130
1131 #[test]
1132 fn session_overrides_effort_round_trips() {
1133 let parsed: super::SessionRequest =
1134 serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
1135 assert_eq!(parsed.effort, Some(super::Effort::Max));
1136
1137 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1138 assert_eq!(parsed.effort, None);
1139 }
1140
1141 #[test]
1142 fn short_prompt_round_trips_and_defaults_to_unset() {
1143 let parsed: super::SessionRequest =
1144 serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
1145 assert_eq!(parsed.short_prompt, Some(true));
1146
1147 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1148 assert_eq!(parsed.short_prompt, None);
1149 }
1150
1151 #[test]
1152 fn skill_filters_are_optional_on_the_wire_and_preserve_empty_overrides() {
1153 let absent: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1154 assert_eq!(absent.include_skills, None);
1155 assert_eq!(absent.exclude_skills, None);
1156 let encoded = serde_json::to_value(absent).unwrap();
1157 assert!(encoded.get("include_skills").is_none());
1158 assert!(encoded.get("exclude_skills").is_none());
1159
1160 let json = serde_json::json!({"include_skills": ["Review"], "exclude_skills": []});
1161 let parsed: SessionRequest = serde_json::from_value(json.clone()).unwrap();
1162 assert_eq!(parsed.include_skills, Some(vec!["Review".to_string()]));
1163 assert_eq!(parsed.exclude_skills, Some(Vec::new()));
1164 assert_eq!(serde_json::to_value(parsed).unwrap(), json);
1165 }
1166
1167 fn pinned_request() -> SessionRequest {
1168 SessionRequest {
1169 model: Some("base-model".to_string()),
1170 provider: Some("anthropic".to_string()),
1171 permission_mode: Some(PermissionMode::Strict),
1172 system_prompt: Some("base prompt".to_string()),
1173 cwd: Some(std::path::PathBuf::from("/base")),
1174 effort: Some(Effort::Medium),
1175 enable_auto_memory: Some(true),
1176 short_prompt: Some(true),
1177 no_skills: Some(true),
1178 include_skills: Some(vec!["review".to_string()]),
1179 exclude_skills: Some(vec!["noisy".to_string()]),
1180 save_session: Some(true),
1181 unattended: Some(true),
1182 title: Some("pinned title".to_string()),
1183 ..Default::default()
1184 }
1185 }
1186
1187 #[test]
1188 fn patched_with_an_empty_patch_keeps_every_field() {
1189 assert_eq!(pinned_request().patched(SessionRequest::default()), pinned_request());
1190 }
1191
1192 #[test]
1193 fn patched_overwrites_only_the_patch_set_fields() {
1194 let patched = pinned_request().patched(SessionRequest {
1195 model: Some("new-model".to_string()),
1196 permission_mode: Some(PermissionMode::Yolo),
1197 enable_auto_memory: Some(false),
1198 short_prompt: Some(false),
1199 include_skills: Some(Vec::new()),
1200 ..Default::default()
1201 });
1202 assert_eq!(patched.model.as_deref(), Some("new-model"));
1204 assert_eq!(patched.permission_mode, Some(PermissionMode::Yolo));
1205 assert_eq!(patched.enable_auto_memory, Some(false));
1206 assert_eq!(patched.short_prompt, Some(false));
1207 assert_eq!(patched.include_skills, Some(Vec::new()));
1208 assert_eq!(patched.provider.as_deref(), Some("anthropic"));
1210 assert_eq!(patched.system_prompt.as_deref(), Some("base prompt"));
1211 assert_eq!(patched.effort, Some(Effort::Medium));
1212 assert_eq!(patched.save_session, Some(true));
1213 assert_eq!(patched.unattended, Some(true));
1214 assert_eq!(patched.title.as_deref(), Some("pinned title"));
1215 assert_eq!(patched.exclude_skills, Some(vec!["noisy".to_string()]));
1216 }
1217
1218 #[test]
1219 fn rewind_names_its_input_as_to() {
1220 let to = Id::op();
1221 let json = serde_json::to_value(Op::RewindSession { to }).unwrap();
1222 assert_eq!(json, serde_json::json!({ "RewindSession": { "to": to } }));
1223 let op: Op = serde_json::from_value(json).unwrap();
1224 assert!(matches!(op, Op::RewindSession { to: decoded } if decoded == to));
1225
1226 let json = serde_json::to_value(Evt::SessionRewound { to }).unwrap();
1227 assert_eq!(json, serde_json::json!({ "SessionRewound": { "to": to } }));
1228 }
1229
1230 #[test]
1231 fn fork_at_is_optional_on_the_wire() {
1232 let whole: Op = serde_json::from_value(serde_json::json!({ "ForkSession": {} })).unwrap();
1233 assert!(matches!(whole, Op::ForkSession { at: None }));
1234
1235 let at = Id::op();
1236 let json = serde_json::to_value(Op::ForkSession { at: Some(at) }).unwrap();
1237 assert_eq!(json, serde_json::json!({ "ForkSession": { "at": at } }));
1238 let op: Op = serde_json::from_value(json).unwrap();
1239 assert!(matches!(op, Op::ForkSession { at: Some(decoded) } if decoded == at));
1240
1241 let (forked_from, session_id) = (Id::ses(), Id::ses());
1242 let forked = Evt::SessionForked { forked_from, session_id, at: None };
1243 assert_eq!(
1244 serde_json::to_value(forked).unwrap(),
1245 serde_json::json!({
1246 "SessionForked": { "forked_from": forked_from, "session_id": session_id }
1247 })
1248 );
1249 }
1250
1251 #[test]
1252 fn forked_from_is_optional_on_the_wire() {
1253 let source = Id::ses();
1254 let info = SessionInfo { forked_from: Some(source), ..Default::default() };
1255 let mut json = serde_json::to_value(&info).unwrap();
1256 assert_eq!(json["forked_from"], serde_json::json!(source));
1257 let decoded: SessionInfo = serde_json::from_value(json.clone()).unwrap();
1258 assert_eq!(decoded.forked_from, Some(source));
1259
1260 json.as_object_mut().unwrap().remove("forked_from");
1262 let plain: SessionInfo = serde_json::from_value(json).unwrap();
1263 assert_eq!(plain.forked_from, None);
1264 assert!(serde_json::to_value(&plain).unwrap().get("forked_from").is_none());
1265 }
1266
1267 #[test]
1268 fn unattended_is_optional_on_the_wire() {
1269 let request: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1272 assert_eq!(request.unattended, None);
1273
1274 let op = Op::ResumeSession { session_id: Id::new("ses"), unattended: true };
1275 let mut json = serde_json::to_value(&op).unwrap();
1276 json["ResumeSession"].as_object_mut().unwrap().remove("unattended");
1277 let decoded: Op = serde_json::from_value(json).unwrap();
1278 assert!(matches!(decoded, Op::ResumeSession { unattended: false, .. }));
1279 }
1280
1281 #[test]
1282 fn session_initialized_title_is_optional_on_the_wire() {
1283 let mut payload = SessionInfo {
1284 model: model_spec("m"),
1285 provider: provider_spec("p"),
1286 session_id: Id::new("ses"),
1287 cwd: PathBuf::from("/tmp"),
1288 permission_mode: PermissionMode::default(),
1289 skills: vec![],
1290 subagents: vec![],
1291 title: None,
1292 forked_from: None,
1293 };
1294
1295 let json = serde_json::to_value(&payload).unwrap();
1296 assert!(json.get("title").is_none(), "an unset title is omitted: {json}");
1297 let decoded: SessionInfo = serde_json::from_value(json).unwrap();
1298 assert_eq!(decoded.title, None);
1299
1300 payload.title = Some("fix".to_string());
1301 let json = serde_json::to_value(&payload).unwrap();
1302 assert_eq!(json["title"], "fix");
1303 }
1304
1305 #[test]
1306 fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
1307 let mut spec = model_spec("m");
1308 spec.weight_class = Some(WeightClass::Heavy);
1309 let json = serde_json::to_value(&spec).unwrap();
1310 assert_eq!(json["weight_class"], "heavy");
1311
1312 let json_none = serde_json::to_value(model_spec("m")).unwrap();
1314 assert!(json_none.get("weight_class").is_none());
1315
1316 let parsed: ModelSpec =
1318 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
1319 .unwrap();
1320 assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
1321 }
1322
1323 #[test]
1324 fn weight_class_deserializes_case_insensitively() {
1325 for (value, expected) in [
1326 ("Feather", WeightClass::Feather),
1327 ("MIDDLE", WeightClass::Middle),
1328 ("hEaVy", WeightClass::Heavy),
1329 ] {
1330 let parsed: ModelSpec =
1331 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
1332 .unwrap();
1333 assert_eq!(parsed.weight_class, Some(expected));
1334 }
1335 }
1336
1337 #[test]
1338 fn weight_class_orders_feather_lightest_to_heavy_heaviest() {
1339 assert!(WeightClass::Feather < WeightClass::Middle);
1340 assert!(WeightClass::Middle < WeightClass::Heavy);
1341 }
1342
1343 fn provider_spec(name: &str) -> ProviderSpec {
1344 ProviderSpec {
1345 id: name.to_string(),
1346 display_name: name.to_string(),
1347 base_url: format!("https://api.{name}.test/v1"),
1348 }
1349 }
1350
1351 #[test]
1352 fn compact_events_serde_roundtrip() {
1353 let compact_start =
1354 serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
1355 let compact_end =
1356 serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
1357 .expect("serialize CompactEnd");
1358
1359 assert_eq!(compact_start, "\"CompactStart\"");
1360 assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
1361
1362 assert!(matches!(
1363 serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
1364 Evt::CompactStart
1365 ));
1366 assert!(matches!(
1367 serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
1368 Evt::CompactEnd { summary: Some(s) } if s == "the summary"
1369 ));
1370 assert!(matches!(
1371 serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
1372 .expect("deserialize CompactEnd without summary"),
1373 Evt::CompactEnd { summary: None }
1374 ));
1375 }
1376
1377 #[test]
1378 fn compact_op_serde_roundtrip() {
1379 let plain = serde_json::to_string(&Op::Compact { instructions: None })
1380 .expect("serialize bare Compact");
1381 assert_eq!(plain, r#"{"Compact":{}}"#);
1382 assert!(matches!(
1383 serde_json::from_str::<Op>(&plain).expect("deserialize bare Compact"),
1384 Op::Compact { instructions: None }
1385 ));
1386
1387 let steered =
1388 serde_json::to_string(&Op::Compact { instructions: Some("keep dates".to_string()) })
1389 .expect("serialize steered Compact");
1390 assert_eq!(steered, r#"{"Compact":{"instructions":"keep dates"}}"#);
1391 assert!(matches!(
1392 serde_json::from_str::<Op>(&steered).expect("deserialize steered Compact"),
1393 Op::Compact { instructions: Some(text) } if text == "keep dates"
1394 ));
1395 }
1396
1397 #[test]
1398 fn session_end_and_turn_resume_serde_roundtrip() {
1399 let session_id = Id::new("ses");
1400 let end = Evt::SessionEnd {
1401 session_id,
1402 reason: super::SessionEndReason::Shutdown,
1403 usage: Usage::new(10, 5),
1404 };
1405 let json = serde_json::to_string(&end).expect("serialize SessionEnd");
1406 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
1407 assert!(matches!(
1408 decoded,
1409 Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
1410 if id == session_id && usage.total() == 15
1411 ));
1412
1413 let turn_id = Id::new("op");
1414 let resume = Evt::TurnResume { turn_id };
1415 let json = serde_json::to_string(&resume).expect("serialize TurnResume");
1416 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
1417 assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
1418 }
1419
1420 #[test]
1421 fn extension_refreshed_serde_roundtrip() {
1422 let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
1423 session_id: Id::new("ses"),
1424 skills: Vec::new(),
1425 subagents: Vec::new(),
1426 mcp_servers: Vec::new(),
1427 }));
1428
1429 let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
1430 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
1431
1432 assert!(matches!(
1433 decoded,
1434 Evt::ExtensionRefreshed(payload)
1435 if payload.skills.is_empty() && payload.subagents.is_empty()
1436 ));
1437 }
1438
1439 #[test]
1440 fn session_update_op_serde_roundtrip() {
1441 let op = Op::UpdateSession(SessionUpdate {
1442 provider: Some("openai".to_string()),
1443 model: Some(ModelSpec {
1444 temperature: Some(0.2),
1445 effort: Some(super::Effort::High),
1446 ..model_spec("gpt-5.4")
1447 }),
1448 permission_mode: Some(PermissionMode::Yolo),
1449 title: Some("renamed".to_string()),
1450 });
1451
1452 let json = serde_json::to_string(&op).expect("serialize UpdateSession");
1453 let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
1454
1455 assert!(matches!(
1456 decoded,
1457 Op::UpdateSession(SessionUpdate {
1458 provider: Some(provider),
1459 model: Some(model),
1460 permission_mode: Some(PermissionMode::Yolo),
1461 title: Some(title),
1462 })
1463 if provider == "openai"
1464 && model.id == "gpt-5.4"
1465 && model.temperature == Some(0.2)
1466 && model.effort == Some(super::Effort::High)
1467 && title == "renamed"
1468 ));
1469 }
1470
1471 #[test]
1472 fn approval_response_uses_named_tool_decisions() {
1473 let turn_id = Id::new("turn");
1474 let op = Op::ApprovalResponse {
1475 turn_id,
1476 responses: vec![ToolDecision {
1477 tool_use_id: "call-1".to_string(),
1478 decision: ReviewDecision::Deny,
1479 message: Some("use the read-only endpoint".to_string()),
1480 }],
1481 };
1482
1483 let json = serde_json::to_value(&op).expect("serialize ApprovalResponse");
1484 assert_eq!(
1485 json["ApprovalResponse"]["responses"],
1486 serde_json::json!([{
1487 "tool_use_id": "call-1",
1488 "decision": "Deny",
1489 "message": "use the read-only endpoint"
1490 }])
1491 );
1492
1493 let decoded = serde_json::from_value::<Op>(json).expect("deserialize ApprovalResponse");
1494 assert!(matches!(
1495 decoded,
1496 Op::ApprovalResponse { turn_id: id, responses }
1497 if id == turn_id
1498 && responses == vec![ToolDecision {
1499 tool_use_id: "call-1".to_string(),
1500 decision: ReviewDecision::Deny,
1501 message: Some("use the read-only endpoint".to_string()),
1502 }]
1503 ));
1504 }
1505
1506 #[test]
1507 fn session_updated_event_serde_roundtrip() {
1508 let session_id = Id::new("ses");
1509 let event = Evt::SessionUpdated(Box::new(SessionInfo {
1510 model: model_spec("claude-sonnet-4-6"),
1511 provider: provider_spec("anthropic"),
1512 session_id,
1513 cwd: PathBuf::from("/tmp/session-updated"),
1514 permission_mode: PermissionMode::default(),
1515 skills: vec![],
1516 subagents: vec![],
1517 title: None,
1518 forked_from: None,
1519 }));
1520
1521 let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
1522 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
1523
1524 assert!(matches!(
1525 decoded,
1526 Evt::SessionUpdated(payload)
1527 if payload.model.id == "claude-sonnet-4-6"
1528 && payload.provider.id == "anthropic"
1529 && payload.provider.base_url == "https://api.anthropic.test/v1"
1530 && payload.session_id == session_id
1531 && payload.cwd == std::path::Path::new("/tmp/session-updated")
1532 ));
1533 }
1534
1535 #[test]
1536 fn provider_spec_ignores_the_dropped_model_list() {
1537 let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
1540 "id": "anthropic",
1541 "display_name": "Anthropic",
1542 "base_url": "https://api.anthropic.test/v1",
1543 "preferred_models": [{ "id": "claude-sonnet-4-6" }],
1544 }))
1545 .unwrap();
1546
1547 assert_eq!(spec.id, "anthropic");
1548 assert_eq!(spec.display_name, "Anthropic");
1549 assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
1550
1551 let encoded = serde_json::to_value(&spec).unwrap();
1553 assert!(encoded.get("preferred_models").is_none());
1554 }
1555
1556 #[test]
1557 fn context_report_serde_roundtrip() {
1558 let breakdown = super::ContextBreakdown {
1559 system_prompt_tokens: 1200,
1560 system_tools_tokens: 3400,
1561 mcp_tools_tokens: 0,
1562 memory_tokens: 800,
1563 skills_tokens: 150,
1564 messages_tokens: 42_000,
1565 used_tokens: 47_550,
1566 limit_tokens: Some(200_000),
1567 compact_buffer_tokens: 20_000,
1568 };
1569 let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1570 assert_eq!(
1571 json,
1572 serde_json::json!({
1573 "ContextReport": {
1574 "system_prompt_tokens": 1200,
1575 "system_tools_tokens": 3400,
1576 "mcp_tools_tokens": 0,
1577 "memory_tokens": 800,
1578 "skills_tokens": 150,
1579 "messages_tokens": 42000,
1580 "used_tokens": 47550,
1581 "limit_tokens": 200000,
1582 "compact_buffer_tokens": 20000
1583 }
1584 })
1585 );
1586 let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1587 assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1588
1589 let sparse: super::ContextBreakdown =
1591 serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1592 assert_eq!(sparse.used_tokens, 10);
1593 assert_eq!(sparse.limit_tokens, None);
1594
1595 let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1596 assert_eq!(op, serde_json::json!("ContextReport"));
1597 assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1598 }
1599
1600 #[test]
1601 fn question_pause_serde_roundtrip() {
1602 let turn_id = Id::new("op");
1603 let pause = Evt::TurnPause {
1604 turn_id,
1605 reason: super::TurnPauseReason::Question {
1606 tool_use_id: "toolu_1".to_string(),
1607 questions: vec![super::QuestionSpec {
1608 header: "Auth method".to_string(),
1609 question: "Which auth method should we use?".to_string(),
1610 multi_select: false,
1611 options: vec![
1612 super::QuestionOption {
1613 label: "JWT (Recommended)".to_string(),
1614 description: "Stateless tokens".to_string(),
1615 preview: None,
1616 },
1617 super::QuestionOption {
1618 label: "Sessions".to_string(),
1619 description: "Server-side sessions".to_string(),
1620 preview: Some("fn login() {}".to_string()),
1621 },
1622 ],
1623 }],
1624 },
1625 };
1626
1627 let json = serde_json::to_value(&pause).expect("serialize question pause");
1628 let spec = &json["TurnPause"]["reason"]["Question"]["questions"][0];
1630 assert!(spec["options"][0].get("preview").is_none());
1631 assert_eq!(spec["options"][1]["preview"], "fn login() {}");
1632
1633 let decoded: Evt = serde_json::from_value(json).expect("deserialize question pause");
1634 let Evt::TurnPause {
1635 reason: super::TurnPauseReason::Question { tool_use_id, questions },
1636 ..
1637 } = decoded
1638 else {
1639 panic!("expected Question pause");
1640 };
1641 assert_eq!(tool_use_id, "toolu_1");
1642 assert_eq!(questions.len(), 1);
1643 assert!(!questions[0].multi_select);
1644
1645 let sparse: super::QuestionSpec = serde_json::from_value(serde_json::json!({
1647 "header": "Scope",
1648 "question": "How broad?",
1649 "options": [],
1650 }))
1651 .unwrap();
1652 assert!(!sparse.multi_select);
1653 }
1654
1655 #[test]
1656 fn question_response_serde_roundtrip() {
1657 let turn_id = Id::new("op");
1658 let op = Op::QuestionResponse {
1659 turn_id,
1660 tool_use_id: "toolu_1".to_string(),
1661 reply: super::QuestionReply::Answered(vec![super::QuestionAnswer {
1662 selected: vec!["JWT".to_string(), "Sessions".to_string()],
1663 note: None,
1664 }]),
1665 };
1666 let json = serde_json::to_string(&op).expect("serialize QuestionResponse");
1667 let decoded: Op = serde_json::from_str(&json).expect("deserialize QuestionResponse");
1668 assert!(matches!(
1669 decoded,
1670 Op::QuestionResponse { tool_use_id, reply: super::QuestionReply::Answered(answers), .. }
1671 if tool_use_id == "toolu_1" && answers[0].selected.len() == 2
1672 ));
1673
1674 for (reply, expected) in [
1677 (super::QuestionReply::Dismissed, serde_json::json!("Dismissed")),
1678 (super::QuestionReply::Discuss { message: None }, serde_json::json!({ "Discuss": {} })),
1679 (
1680 super::QuestionReply::Discuss { message: Some("later".to_string()) },
1681 serde_json::json!({ "Discuss": { "message": "later" } }),
1682 ),
1683 ] {
1684 let json = serde_json::to_value(&reply).expect("serialize reply");
1685 assert_eq!(json, expected);
1686 let decoded: super::QuestionReply =
1687 serde_json::from_value(json).expect("deserialize reply");
1688 assert_eq!(decoded, reply);
1689 }
1690 }
1691
1692 #[test]
1693 fn usage_adds_cache_fields_without_overflowing() {
1694 let mut usage = Usage {
1695 input_tokens: 10,
1696 output_tokens: 20,
1697 cache_read_tokens: Some(3),
1698 cache_creation_tokens: None,
1699 };
1700 usage += Usage {
1701 input_tokens: 5,
1702 output_tokens: 6,
1703 cache_read_tokens: Some(4),
1704 cache_creation_tokens: Some(8),
1705 };
1706
1707 assert_eq!(usage.input_tokens, 15);
1708 assert_eq!(usage.output_tokens, 26);
1709 assert_eq!(usage.total(), 41);
1710 assert_eq!(usage.cache_read_tokens, Some(7));
1711 assert_eq!(usage.cache_creation_tokens, Some(8));
1712 }
1713}