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