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)]
551pub struct McpServerInfo {
552 pub name: String,
553 pub command: String,
554 pub args: Vec<String>,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub url: Option<String>,
557 pub tools: Vec<McpToolInfo>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct McpToolInfo {
562 pub name: String,
563 pub qualified_name: String,
564 pub description: String,
565 pub parameters: Vec<McpToolParam>,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct McpToolParam {
570 pub name: String,
571 pub param_type: String,
572 pub required: bool,
573 pub description: String,
574}
575
576#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
583pub struct SessionRequest {
584 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub model: Option<String>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub provider: Option<String>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub permission_mode: Option<PermissionMode>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub system_prompt: Option<String>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub append_system_prompt: Option<String>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub tools: Option<Vec<String>>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub include_tools: Option<Vec<String>>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub exclude_tools: Option<Vec<String>>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub cwd: Option<PathBuf>,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub effort: Option<Effort>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub enable_auto_memory: Option<bool>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub short_prompt: Option<bool>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub no_skills: Option<bool>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub include_skills: Option<Vec<String>>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub exclude_skills: Option<Vec<String>>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub save_session: Option<bool>,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
630 pub unattended: Option<bool>,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub title: Option<String>,
634}
635
636impl SessionRequest {
637 pub fn patched(self, patch: SessionRequest) -> SessionRequest {
644 let SessionRequest {
645 model,
646 provider,
647 permission_mode,
648 system_prompt,
649 append_system_prompt,
650 tools,
651 include_tools,
652 exclude_tools,
653 cwd,
654 effort,
655 enable_auto_memory,
656 short_prompt,
657 no_skills,
658 include_skills,
659 exclude_skills,
660 save_session,
661 unattended,
662 title,
663 } = patch;
664 SessionRequest {
665 model: model.or(self.model),
666 provider: provider.or(self.provider),
667 permission_mode: permission_mode.or(self.permission_mode),
668 system_prompt: system_prompt.or(self.system_prompt),
669 append_system_prompt: append_system_prompt.or(self.append_system_prompt),
670 tools: tools.or(self.tools),
671 include_tools: include_tools.or(self.include_tools),
672 exclude_tools: exclude_tools.or(self.exclude_tools),
673 cwd: cwd.or(self.cwd),
674 effort: effort.or(self.effort),
675 enable_auto_memory: enable_auto_memory.or(self.enable_auto_memory),
676 short_prompt: short_prompt.or(self.short_prompt),
677 no_skills: no_skills.or(self.no_skills),
678 include_skills: include_skills.or(self.include_skills),
679 exclude_skills: exclude_skills.or(self.exclude_skills),
680 save_session: save_session.or(self.save_session),
681 unattended: unattended.or(self.unattended),
682 title: title.or(self.title),
683 }
684 }
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
688pub struct MalformedToolArgs {
689 pub raw: String,
691 pub error: String,
693}
694
695pub const MISSING_TOOL_NAME: &str = "missing_function_name";
698
699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
700pub struct ToolUse {
701 pub id: String,
702 pub name: String,
703 pub args: serde_json::Value,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub malformed_args: Option<MalformedToolArgs>,
711 #[serde(skip_serializing_if = "Option::is_none")]
712 pub signature: Option<String>,
713}
714
715impl ToolUse {
716 pub fn new(id: impl Into<String>, name: impl Into<String>, args: serde_json::Value) -> Self {
718 Self { id: id.into(), name: name.into(), args, malformed_args: None, signature: None }
719 }
720}
721
722#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
723pub struct ModelSpec {
724 #[serde(alias = "name")]
725 pub id: String,
726 #[serde(skip_serializing_if = "Option::is_none")]
727 pub display_name: Option<String>,
728 #[serde(skip_serializing_if = "Option::is_none")]
729 pub description: Option<String>,
730 #[serde(skip_serializing_if = "Option::is_none")]
731 pub temperature: Option<f32>,
732 #[serde(skip_serializing_if = "Option::is_none")]
733 pub top_p: Option<f32>,
734 #[serde(skip_serializing_if = "Option::is_none")]
735 pub top_k: Option<u32>,
736 #[serde(skip_serializing_if = "Option::is_none")]
737 pub max_tokens: Option<u32>,
738 #[serde(skip_serializing_if = "Option::is_none")]
739 pub stop_sequences: Option<Vec<String>>,
740 #[serde(skip_serializing_if = "Option::is_none")]
741 pub context_limit: Option<u32>,
742 #[serde(skip_serializing_if = "Option::is_none")]
743 pub effort: Option<Effort>,
744 #[serde(skip_serializing_if = "Option::is_none")]
748 pub supported_efforts: Option<Vec<Effort>>,
749 #[serde(skip_serializing_if = "Option::is_none")]
750 pub support_vision: Option<bool>,
751 #[serde(skip_serializing_if = "Option::is_none")]
752 pub weight_class: Option<WeightClass>,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
763#[serde(rename_all = "lowercase")]
764pub enum Effort {
765 Min,
766 Low,
767 Medium,
768 High,
769 XHigh,
770 Max,
771}
772
773impl Effort {
774 pub const ALL: [Effort; 6] =
776 [Effort::Min, Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max];
777
778 pub fn as_str(self) -> &'static str {
780 match self {
781 Effort::Min => "min",
782 Effort::Low => "low",
783 Effort::Medium => "medium",
784 Effort::High => "high",
785 Effort::XHigh => "xhigh",
786 Effort::Max => "max",
787 }
788 }
789}
790
791impl std::fmt::Display for Effort {
792 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793 f.write_str(self.as_str())
794 }
795}
796
797impl std::str::FromStr for Effort {
798 type Err = String;
799
800 fn from_str(s: &str) -> Result<Self, Self::Err> {
801 Effort::ALL.into_iter().find(|e| e.as_str() == s).ok_or_else(|| {
802 format!("unknown effort `{s}` (expected min, low, medium, high, xhigh, max)")
803 })
804 }
805}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
814#[serde(rename_all = "lowercase")]
815pub enum WeightClass {
816 Feather,
818 Middle,
820 Heavy,
822}
823
824impl<'de> Deserialize<'de> for WeightClass {
825 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
826 where
827 D: Deserializer<'de>,
828 {
829 let value = String::deserialize(deserializer)?;
830 match value.to_ascii_lowercase().as_str() {
831 "feather" => Ok(Self::Feather),
832 "middle" => Ok(Self::Middle),
833 "heavy" => Ok(Self::Heavy),
834 _ => Err(serde::de::Error::unknown_variant(&value, &["feather", "middle", "heavy"])),
835 }
836 }
837}
838
839#[derive(Debug, Clone, Deserialize, Serialize, Default, Copy)]
856#[serde(default)]
857pub struct Usage {
858 pub input_tokens: u32,
860 pub output_tokens: u32,
862 #[serde(skip_serializing_if = "Option::is_none")]
864 pub cache_read_tokens: Option<u32>,
865 #[serde(skip_serializing_if = "Option::is_none")]
867 pub cache_creation_tokens: Option<u32>,
868}
869
870#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
874pub struct ContextWindow {
875 pub used_tokens: u32,
878 pub limit_tokens: u32,
880}
881
882#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
890#[serde(default)]
891pub struct ContextBreakdown {
892 pub system_prompt_tokens: u32,
894 pub system_tools_tokens: u32,
896 pub mcp_tools_tokens: u32,
898 pub memory_tokens: u32,
900 pub skills_tokens: u32,
902 pub messages_tokens: u32,
904 pub used_tokens: u32,
906 pub limit_tokens: Option<u32>,
909 pub compact_buffer_tokens: u32,
912}
913
914impl Usage {
915 pub fn new(input_tokens: u32, output_tokens: u32) -> Self {
916 Self { input_tokens, output_tokens, cache_read_tokens: None, cache_creation_tokens: None }
917 }
918
919 pub fn total(&self) -> u32 {
921 self.input_tokens.saturating_add(self.output_tokens)
922 }
923}
924
925impl std::ops::Add<Usage> for Usage {
926 type Output = Usage;
927
928 fn add(self, other: Usage) -> Usage {
929 Usage {
930 input_tokens: self.input_tokens.saturating_add(other.input_tokens),
931 output_tokens: self.output_tokens.saturating_add(other.output_tokens),
932 cache_read_tokens: add_optional_u32(self.cache_read_tokens, other.cache_read_tokens),
933 cache_creation_tokens: add_optional_u32(
934 self.cache_creation_tokens,
935 other.cache_creation_tokens,
936 ),
937 }
938 }
939}
940
941fn add_optional_u32(a: Option<u32>, b: Option<u32>) -> Option<u32> {
942 match (a, b) {
943 (None, None) => None,
944 _ => Some(a.unwrap_or(0).saturating_add(b.unwrap_or(0))),
945 }
946}
947
948impl std::ops::AddAssign<Usage> for Usage {
949 fn add_assign(&mut self, other: Usage) {
950 *self = *self + other;
951 }
952}
953
954#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, Eq, PartialEq)]
955#[serde(rename_all = "snake_case")]
956pub enum PermissionMode {
957 #[default]
959 Strict,
960 Auto,
963 Yolo,
965}
966
967#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
968#[serde(rename_all = "kebab-case")]
969pub enum Scope {
970 Project,
971 User,
972 System,
973}
974
975#[cfg(test)]
976mod tests {
977 use super::{
978 Effort, Evt, ExtensionRefreshed, Id, ModelSpec, Op, PermissionMode, ProviderSpec,
979 ReviewDecision, SessionInfo, SessionRequest, SessionUpdate, ToolDecision, ToolEnd,
980 ToolEndStatus, ToolUse, Usage, WeightClass, event_msg, op_msg,
981 };
982 use std::path::PathBuf;
983
984 #[test]
985 fn op_msg_assigns_runtime_id() {
986 let msg = op_msg(Op::Interrupt);
987
988 assert!(msg.id.to_string().starts_with("op_"));
989 assert!(matches!(msg.op, Op::Interrupt));
990 }
991
992 #[test]
993 fn event_msg_assigns_runtime_metadata() {
994 let event = event_msg(Evt::Info("hello".to_string()), None);
995
996 assert!(event.id.to_string().starts_with("evt_"));
997 assert!(matches!(event.event, Evt::Info(message) if message == "hello"));
998 assert!(event.parent.is_none());
999 }
1000
1001 fn model_spec(name: &str) -> ModelSpec {
1002 ModelSpec {
1003 id: name.to_string(),
1004 display_name: None,
1005 description: None,
1006 temperature: None,
1007 top_p: None,
1008 top_k: None,
1009 max_tokens: None,
1010 stop_sequences: None,
1011 context_limit: None,
1012 effort: None,
1013 supported_efforts: None,
1014 support_vision: None,
1015 weight_class: None,
1016 }
1017 }
1018
1019 #[test]
1020 fn tool_use_without_malformed_args_remains_backward_compatible() {
1021 let tool_use: ToolUse = serde_json::from_value(serde_json::json!({
1022 "id": "call-1",
1023 "name": "Read",
1024 "args": { "file_path": "README.md" }
1025 }))
1026 .unwrap();
1027
1028 assert!(tool_use.malformed_args.is_none());
1029 let encoded = serde_json::to_value(tool_use).unwrap();
1030 assert!(encoded.get("malformed_args").is_none());
1031 }
1032
1033 #[test]
1034 fn tool_end_without_name_remains_backward_compatible() {
1035 let legacy = serde_json::json!({
1036 "tool_use_id": "call-1",
1037 "status": "Denied",
1038 "result_json": "Tool call denied by policy and was not executed."
1039 });
1040 let mut end: ToolEnd = serde_json::from_value(legacy.clone()).unwrap();
1041 assert!(end.tool_name.is_empty());
1042 assert_eq!(end.status, ToolEndStatus::Denied);
1043
1044 end.tool_name = "Bash".to_string();
1045 let mut expected = legacy;
1046 expected["tool_name"] = serde_json::json!("Bash");
1047 assert_eq!(serde_json::to_value(end).unwrap(), expected);
1048 }
1049
1050 #[test]
1051 fn task_end_round_trips_with_observed_or_unobserved_exit() {
1052 for exit_code in [Some(2), None] {
1053 let json = serde_json::json!({
1054 "TaskEnd": { "tool_use_id": "bash-1", "exit_code": exit_code }
1055 });
1056 let event: super::Evt = serde_json::from_value(json.clone()).unwrap();
1057 assert!(matches!(&event, super::Evt::TaskEnd { tool_use_id, exit_code: code }
1058 if tool_use_id == "bash-1" && *code == exit_code));
1059 assert_eq!(serde_json::to_value(event).unwrap(), json);
1060 }
1061 }
1062
1063 #[test]
1064 fn turn_end_error_kind_is_optional_on_the_wire() {
1065 let old: super::TurnEndStatus = serde_json::from_value(serde_json::json!({
1067 "Error": { "headline": "authentication error", "details": [] }
1068 }))
1069 .unwrap();
1070 let super::TurnEndStatus::Error { kind, .. } = &old else {
1071 panic!("expected Error variant");
1072 };
1073 assert!(kind.is_none());
1074
1075 let json = serde_json::to_value(&old).unwrap();
1077 assert!(json["Error"].get("kind").is_none());
1078
1079 let with = super::TurnEndStatus::Error {
1081 kind: Some("oauth".to_string()),
1082 headline: "OAuth sign-in required".to_string(),
1083 details: vec![],
1084 };
1085 let json = serde_json::to_value(&with).unwrap();
1086 assert_eq!(json["Error"]["kind"], "oauth");
1087 }
1088
1089 #[test]
1090 fn turn_end_steps_default_for_older_events() {
1091 let event = super::Evt::TurnEnd {
1092 turn_id: super::Id::new("turn"),
1093 status: super::TurnEndStatus::Completed,
1094 steps: 3,
1095 };
1096 let mut json = serde_json::to_value(&event).unwrap();
1097 json["TurnEnd"].as_object_mut().unwrap().remove("steps");
1098 let old: super::Evt = serde_json::from_value(json).unwrap();
1099
1100 assert!(matches!(old, super::Evt::TurnEnd { steps: 0, .. }));
1101
1102 let json = serde_json::to_value(event).unwrap();
1103 assert_eq!(json["TurnEnd"]["steps"], 3);
1104 }
1105
1106 #[test]
1107 fn effort_serializes_as_lowercase_tokens() {
1108 let mut spec = model_spec("m");
1109 spec.effort = Some(super::Effort::XHigh);
1110 let json = serde_json::to_value(&spec).unwrap();
1111 assert_eq!(json["effort"], "xhigh");
1112
1113 let json_none = serde_json::to_value(model_spec("m")).unwrap();
1115 assert!(json_none.get("effort").is_none());
1116
1117 for level in super::Effort::ALL {
1119 let parsed: ModelSpec =
1120 serde_json::from_value(serde_json::json!({"id": "m", "effort": level.as_str()}))
1121 .unwrap();
1122 assert_eq!(parsed.effort, Some(level), "round-trip {level}");
1123 }
1124 }
1125
1126 #[test]
1127 fn session_overrides_effort_round_trips() {
1128 let parsed: super::SessionRequest =
1129 serde_json::from_value(serde_json::json!({"effort": "max"})).unwrap();
1130 assert_eq!(parsed.effort, Some(super::Effort::Max));
1131
1132 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1133 assert_eq!(parsed.effort, None);
1134 }
1135
1136 #[test]
1137 fn short_prompt_round_trips_and_defaults_to_unset() {
1138 let parsed: super::SessionRequest =
1139 serde_json::from_value(serde_json::json!({"short_prompt": true})).unwrap();
1140 assert_eq!(parsed.short_prompt, Some(true));
1141
1142 let parsed: super::SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1143 assert_eq!(parsed.short_prompt, None);
1144 }
1145
1146 #[test]
1147 fn skill_filters_are_optional_on_the_wire_and_preserve_empty_overrides() {
1148 let absent: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1149 assert_eq!(absent.include_skills, None);
1150 assert_eq!(absent.exclude_skills, None);
1151 let encoded = serde_json::to_value(absent).unwrap();
1152 assert!(encoded.get("include_skills").is_none());
1153 assert!(encoded.get("exclude_skills").is_none());
1154
1155 let json = serde_json::json!({"include_skills": ["Review"], "exclude_skills": []});
1156 let parsed: SessionRequest = serde_json::from_value(json.clone()).unwrap();
1157 assert_eq!(parsed.include_skills, Some(vec!["Review".to_string()]));
1158 assert_eq!(parsed.exclude_skills, Some(Vec::new()));
1159 assert_eq!(serde_json::to_value(parsed).unwrap(), json);
1160 }
1161
1162 fn pinned_request() -> SessionRequest {
1163 SessionRequest {
1164 model: Some("base-model".to_string()),
1165 provider: Some("anthropic".to_string()),
1166 permission_mode: Some(PermissionMode::Strict),
1167 system_prompt: Some("base prompt".to_string()),
1168 cwd: Some(std::path::PathBuf::from("/base")),
1169 effort: Some(Effort::Medium),
1170 enable_auto_memory: Some(true),
1171 short_prompt: Some(true),
1172 no_skills: Some(true),
1173 include_skills: Some(vec!["review".to_string()]),
1174 exclude_skills: Some(vec!["noisy".to_string()]),
1175 save_session: Some(true),
1176 unattended: Some(true),
1177 title: Some("pinned title".to_string()),
1178 ..Default::default()
1179 }
1180 }
1181
1182 #[test]
1183 fn patched_with_an_empty_patch_keeps_every_field() {
1184 assert_eq!(pinned_request().patched(SessionRequest::default()), pinned_request());
1185 }
1186
1187 #[test]
1188 fn patched_overwrites_only_the_patch_set_fields() {
1189 let patched = pinned_request().patched(SessionRequest {
1190 model: Some("new-model".to_string()),
1191 permission_mode: Some(PermissionMode::Yolo),
1192 enable_auto_memory: Some(false),
1193 short_prompt: Some(false),
1194 include_skills: Some(Vec::new()),
1195 ..Default::default()
1196 });
1197 assert_eq!(patched.model.as_deref(), Some("new-model"));
1199 assert_eq!(patched.permission_mode, Some(PermissionMode::Yolo));
1200 assert_eq!(patched.enable_auto_memory, Some(false));
1201 assert_eq!(patched.short_prompt, Some(false));
1202 assert_eq!(patched.include_skills, Some(Vec::new()));
1203 assert_eq!(patched.provider.as_deref(), Some("anthropic"));
1205 assert_eq!(patched.system_prompt.as_deref(), Some("base prompt"));
1206 assert_eq!(patched.effort, Some(Effort::Medium));
1207 assert_eq!(patched.save_session, Some(true));
1208 assert_eq!(patched.unattended, Some(true));
1209 assert_eq!(patched.title.as_deref(), Some("pinned title"));
1210 assert_eq!(patched.exclude_skills, Some(vec!["noisy".to_string()]));
1211 }
1212
1213 #[test]
1214 fn rewind_names_its_input_as_to() {
1215 let to = Id::op();
1216 let json = serde_json::to_value(Op::RewindSession { to }).unwrap();
1217 assert_eq!(json, serde_json::json!({ "RewindSession": { "to": to } }));
1218 let op: Op = serde_json::from_value(json).unwrap();
1219 assert!(matches!(op, Op::RewindSession { to: decoded } if decoded == to));
1220
1221 let json = serde_json::to_value(Evt::SessionRewound { to }).unwrap();
1222 assert_eq!(json, serde_json::json!({ "SessionRewound": { "to": to } }));
1223 }
1224
1225 #[test]
1226 fn fork_at_is_optional_on_the_wire() {
1227 let whole: Op = serde_json::from_value(serde_json::json!({ "ForkSession": {} })).unwrap();
1228 assert!(matches!(whole, Op::ForkSession { at: None }));
1229
1230 let at = Id::op();
1231 let json = serde_json::to_value(Op::ForkSession { at: Some(at) }).unwrap();
1232 assert_eq!(json, serde_json::json!({ "ForkSession": { "at": at } }));
1233 let op: Op = serde_json::from_value(json).unwrap();
1234 assert!(matches!(op, Op::ForkSession { at: Some(decoded) } if decoded == at));
1235
1236 let (forked_from, session_id) = (Id::ses(), Id::ses());
1237 let forked = Evt::SessionForked { forked_from, session_id, at: None };
1238 assert_eq!(
1239 serde_json::to_value(forked).unwrap(),
1240 serde_json::json!({
1241 "SessionForked": { "forked_from": forked_from, "session_id": session_id }
1242 })
1243 );
1244 }
1245
1246 #[test]
1247 fn forked_from_is_optional_on_the_wire() {
1248 let source = Id::ses();
1249 let info = SessionInfo { forked_from: Some(source), ..Default::default() };
1250 let mut json = serde_json::to_value(&info).unwrap();
1251 assert_eq!(json["forked_from"], serde_json::json!(source));
1252 let decoded: SessionInfo = serde_json::from_value(json.clone()).unwrap();
1253 assert_eq!(decoded.forked_from, Some(source));
1254
1255 json.as_object_mut().unwrap().remove("forked_from");
1257 let plain: SessionInfo = serde_json::from_value(json).unwrap();
1258 assert_eq!(plain.forked_from, None);
1259 assert!(serde_json::to_value(&plain).unwrap().get("forked_from").is_none());
1260 }
1261
1262 #[test]
1263 fn unattended_is_optional_on_the_wire() {
1264 let request: SessionRequest = serde_json::from_value(serde_json::json!({})).unwrap();
1267 assert_eq!(request.unattended, None);
1268
1269 let op = Op::ResumeSession { session_id: Id::new("ses"), unattended: true };
1270 let mut json = serde_json::to_value(&op).unwrap();
1271 json["ResumeSession"].as_object_mut().unwrap().remove("unattended");
1272 let decoded: Op = serde_json::from_value(json).unwrap();
1273 assert!(matches!(decoded, Op::ResumeSession { unattended: false, .. }));
1274 }
1275
1276 #[test]
1277 fn session_initialized_title_is_optional_on_the_wire() {
1278 let mut payload = SessionInfo {
1279 model: model_spec("m"),
1280 provider: provider_spec("p"),
1281 session_id: Id::new("ses"),
1282 cwd: PathBuf::from("/tmp"),
1283 permission_mode: PermissionMode::default(),
1284 skills: vec![],
1285 subagents: vec![],
1286 title: None,
1287 forked_from: None,
1288 };
1289
1290 let json = serde_json::to_value(&payload).unwrap();
1291 assert!(json.get("title").is_none(), "an unset title is omitted: {json}");
1292 let decoded: SessionInfo = serde_json::from_value(json).unwrap();
1293 assert_eq!(decoded.title, None);
1294
1295 payload.title = Some("fix".to_string());
1296 let json = serde_json::to_value(&payload).unwrap();
1297 assert_eq!(json["title"], "fix");
1298 }
1299
1300 #[test]
1301 fn weight_class_serializes_lowercase_and_is_omitted_when_none() {
1302 let mut spec = model_spec("m");
1303 spec.weight_class = Some(WeightClass::Heavy);
1304 let json = serde_json::to_value(&spec).unwrap();
1305 assert_eq!(json["weight_class"], "heavy");
1306
1307 let json_none = serde_json::to_value(model_spec("m")).unwrap();
1309 assert!(json_none.get("weight_class").is_none());
1310
1311 let parsed: ModelSpec =
1313 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": "feather"}))
1314 .unwrap();
1315 assert_eq!(parsed.weight_class, Some(WeightClass::Feather));
1316 }
1317
1318 #[test]
1319 fn weight_class_deserializes_case_insensitively() {
1320 for (value, expected) in [
1321 ("Feather", WeightClass::Feather),
1322 ("MIDDLE", WeightClass::Middle),
1323 ("hEaVy", WeightClass::Heavy),
1324 ] {
1325 let parsed: ModelSpec =
1326 serde_json::from_value(serde_json::json!({"id": "m", "weight_class": value}))
1327 .unwrap();
1328 assert_eq!(parsed.weight_class, Some(expected));
1329 }
1330 }
1331
1332 fn provider_spec(name: &str) -> ProviderSpec {
1333 ProviderSpec {
1334 id: name.to_string(),
1335 display_name: name.to_string(),
1336 base_url: format!("https://api.{name}.test/v1"),
1337 }
1338 }
1339
1340 #[test]
1341 fn compact_events_serde_roundtrip() {
1342 let compact_start =
1343 serde_json::to_string(&Evt::CompactStart).expect("serialize CompactStart");
1344 let compact_end =
1345 serde_json::to_string(&Evt::CompactEnd { summary: Some("the summary".to_string()) })
1346 .expect("serialize CompactEnd");
1347
1348 assert_eq!(compact_start, "\"CompactStart\"");
1349 assert_eq!(compact_end, r#"{"CompactEnd":{"summary":"the summary"}}"#);
1350
1351 assert!(matches!(
1352 serde_json::from_str::<Evt>(&compact_start).expect("deserialize CompactStart"),
1353 Evt::CompactStart
1354 ));
1355 assert!(matches!(
1356 serde_json::from_str::<Evt>(&compact_end).expect("deserialize CompactEnd"),
1357 Evt::CompactEnd { summary: Some(s) } if s == "the summary"
1358 ));
1359 assert!(matches!(
1360 serde_json::from_str::<Evt>(r#"{"CompactEnd":{}}"#)
1361 .expect("deserialize CompactEnd without summary"),
1362 Evt::CompactEnd { summary: None }
1363 ));
1364 }
1365
1366 #[test]
1367 fn compact_op_serde_roundtrip() {
1368 let plain = serde_json::to_string(&Op::Compact { instructions: None })
1369 .expect("serialize bare Compact");
1370 assert_eq!(plain, r#"{"Compact":{}}"#);
1371 assert!(matches!(
1372 serde_json::from_str::<Op>(&plain).expect("deserialize bare Compact"),
1373 Op::Compact { instructions: None }
1374 ));
1375
1376 let steered =
1377 serde_json::to_string(&Op::Compact { instructions: Some("keep dates".to_string()) })
1378 .expect("serialize steered Compact");
1379 assert_eq!(steered, r#"{"Compact":{"instructions":"keep dates"}}"#);
1380 assert!(matches!(
1381 serde_json::from_str::<Op>(&steered).expect("deserialize steered Compact"),
1382 Op::Compact { instructions: Some(text) } if text == "keep dates"
1383 ));
1384 }
1385
1386 #[test]
1387 fn session_end_and_turn_resume_serde_roundtrip() {
1388 let session_id = Id::new("ses");
1389 let end = Evt::SessionEnd {
1390 session_id,
1391 reason: super::SessionEndReason::Shutdown,
1392 usage: Usage::new(10, 5),
1393 };
1394 let json = serde_json::to_string(&end).expect("serialize SessionEnd");
1395 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionEnd");
1396 assert!(matches!(
1397 decoded,
1398 Evt::SessionEnd { session_id: id, reason: super::SessionEndReason::Shutdown, usage }
1399 if id == session_id && usage.total() == 15
1400 ));
1401
1402 let turn_id = Id::new("op");
1403 let resume = Evt::TurnResume { turn_id };
1404 let json = serde_json::to_string(&resume).expect("serialize TurnResume");
1405 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize TurnResume");
1406 assert!(matches!(decoded, Evt::TurnResume { turn_id: id } if id == turn_id));
1407 }
1408
1409 #[test]
1410 fn extension_refreshed_serde_roundtrip() {
1411 let event = Evt::ExtensionRefreshed(Box::new(ExtensionRefreshed {
1412 session_id: Id::new("ses"),
1413 skills: Vec::new(),
1414 subagents: Vec::new(),
1415 mcp_servers: Vec::new(),
1416 }));
1417
1418 let json = serde_json::to_string(&event).expect("serialize ExtensionRefreshed");
1419 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize ExtensionRefreshed");
1420
1421 assert!(matches!(
1422 decoded,
1423 Evt::ExtensionRefreshed(payload)
1424 if payload.skills.is_empty() && payload.subagents.is_empty()
1425 ));
1426 }
1427
1428 #[test]
1429 fn session_update_op_serde_roundtrip() {
1430 let op = Op::UpdateSession(SessionUpdate {
1431 provider: Some("openai".to_string()),
1432 model: Some(ModelSpec {
1433 temperature: Some(0.2),
1434 effort: Some(super::Effort::High),
1435 ..model_spec("gpt-5.4")
1436 }),
1437 permission_mode: Some(PermissionMode::Yolo),
1438 title: Some("renamed".to_string()),
1439 });
1440
1441 let json = serde_json::to_string(&op).expect("serialize UpdateSession");
1442 let decoded = serde_json::from_str::<Op>(&json).expect("deserialize UpdateSession");
1443
1444 assert!(matches!(
1445 decoded,
1446 Op::UpdateSession(SessionUpdate {
1447 provider: Some(provider),
1448 model: Some(model),
1449 permission_mode: Some(PermissionMode::Yolo),
1450 title: Some(title),
1451 })
1452 if provider == "openai"
1453 && model.id == "gpt-5.4"
1454 && model.temperature == Some(0.2)
1455 && model.effort == Some(super::Effort::High)
1456 && title == "renamed"
1457 ));
1458 }
1459
1460 #[test]
1461 fn approval_response_uses_named_tool_decisions() {
1462 let turn_id = Id::new("turn");
1463 let op = Op::ApprovalResponse {
1464 turn_id,
1465 responses: vec![ToolDecision {
1466 tool_use_id: "call-1".to_string(),
1467 decision: ReviewDecision::Deny,
1468 message: Some("use the read-only endpoint".to_string()),
1469 }],
1470 };
1471
1472 let json = serde_json::to_value(&op).expect("serialize ApprovalResponse");
1473 assert_eq!(
1474 json["ApprovalResponse"]["responses"],
1475 serde_json::json!([{
1476 "tool_use_id": "call-1",
1477 "decision": "Deny",
1478 "message": "use the read-only endpoint"
1479 }])
1480 );
1481
1482 let decoded = serde_json::from_value::<Op>(json).expect("deserialize ApprovalResponse");
1483 assert!(matches!(
1484 decoded,
1485 Op::ApprovalResponse { turn_id: id, responses }
1486 if id == turn_id
1487 && responses == vec![ToolDecision {
1488 tool_use_id: "call-1".to_string(),
1489 decision: ReviewDecision::Deny,
1490 message: Some("use the read-only endpoint".to_string()),
1491 }]
1492 ));
1493 }
1494
1495 #[test]
1496 fn session_updated_event_serde_roundtrip() {
1497 let session_id = Id::new("ses");
1498 let event = Evt::SessionUpdated(Box::new(SessionInfo {
1499 model: model_spec("claude-sonnet-4-6"),
1500 provider: provider_spec("anthropic"),
1501 session_id,
1502 cwd: PathBuf::from("/tmp/session-updated"),
1503 permission_mode: PermissionMode::default(),
1504 skills: vec![],
1505 subagents: vec![],
1506 title: None,
1507 forked_from: None,
1508 }));
1509
1510 let json = serde_json::to_string(&event).expect("serialize SessionUpdated");
1511 let decoded = serde_json::from_str::<Evt>(&json).expect("deserialize SessionUpdated");
1512
1513 assert!(matches!(
1514 decoded,
1515 Evt::SessionUpdated(payload)
1516 if payload.model.id == "claude-sonnet-4-6"
1517 && payload.provider.id == "anthropic"
1518 && payload.provider.base_url == "https://api.anthropic.test/v1"
1519 && payload.session_id == session_id
1520 && payload.cwd == std::path::Path::new("/tmp/session-updated")
1521 ));
1522 }
1523
1524 #[test]
1525 fn provider_spec_ignores_the_dropped_model_list() {
1526 let spec: ProviderSpec = serde_json::from_value(serde_json::json!({
1529 "id": "anthropic",
1530 "display_name": "Anthropic",
1531 "base_url": "https://api.anthropic.test/v1",
1532 "preferred_models": [{ "id": "claude-sonnet-4-6" }],
1533 }))
1534 .unwrap();
1535
1536 assert_eq!(spec.id, "anthropic");
1537 assert_eq!(spec.display_name, "Anthropic");
1538 assert_eq!(spec.base_url, "https://api.anthropic.test/v1");
1539
1540 let encoded = serde_json::to_value(&spec).unwrap();
1542 assert!(encoded.get("preferred_models").is_none());
1543 }
1544
1545 #[test]
1546 fn context_report_serde_roundtrip() {
1547 let breakdown = super::ContextBreakdown {
1548 system_prompt_tokens: 1200,
1549 system_tools_tokens: 3400,
1550 mcp_tools_tokens: 0,
1551 memory_tokens: 800,
1552 skills_tokens: 150,
1553 messages_tokens: 42_000,
1554 used_tokens: 47_550,
1555 limit_tokens: Some(200_000),
1556 compact_buffer_tokens: 20_000,
1557 };
1558 let json = serde_json::to_value(Evt::ContextReport(breakdown)).expect("serialize");
1559 assert_eq!(
1560 json,
1561 serde_json::json!({
1562 "ContextReport": {
1563 "system_prompt_tokens": 1200,
1564 "system_tools_tokens": 3400,
1565 "mcp_tools_tokens": 0,
1566 "memory_tokens": 800,
1567 "skills_tokens": 150,
1568 "messages_tokens": 42000,
1569 "used_tokens": 47550,
1570 "limit_tokens": 200000,
1571 "compact_buffer_tokens": 20000
1572 }
1573 })
1574 );
1575 let decoded = serde_json::from_value::<Evt>(json).expect("deserialize");
1576 assert!(matches!(decoded, Evt::ContextReport(b) if b == breakdown));
1577
1578 let sparse: super::ContextBreakdown =
1580 serde_json::from_value(serde_json::json!({"used_tokens": 10})).unwrap();
1581 assert_eq!(sparse.used_tokens, 10);
1582 assert_eq!(sparse.limit_tokens, None);
1583
1584 let op = serde_json::to_value(Op::ContextReport).expect("serialize op");
1585 assert_eq!(op, serde_json::json!("ContextReport"));
1586 assert!(matches!(serde_json::from_value::<Op>(op).unwrap(), Op::ContextReport));
1587 }
1588
1589 #[test]
1590 fn question_pause_serde_roundtrip() {
1591 let turn_id = Id::new("op");
1592 let pause = Evt::TurnPause {
1593 turn_id,
1594 reason: super::TurnPauseReason::Question {
1595 tool_use_id: "toolu_1".to_string(),
1596 questions: vec![super::QuestionSpec {
1597 header: "Auth method".to_string(),
1598 question: "Which auth method should we use?".to_string(),
1599 multi_select: false,
1600 options: vec![
1601 super::QuestionOption {
1602 label: "JWT (Recommended)".to_string(),
1603 description: "Stateless tokens".to_string(),
1604 preview: None,
1605 },
1606 super::QuestionOption {
1607 label: "Sessions".to_string(),
1608 description: "Server-side sessions".to_string(),
1609 preview: Some("fn login() {}".to_string()),
1610 },
1611 ],
1612 }],
1613 },
1614 };
1615
1616 let json = serde_json::to_value(&pause).expect("serialize question pause");
1617 let spec = &json["TurnPause"]["reason"]["Question"]["questions"][0];
1619 assert!(spec["options"][0].get("preview").is_none());
1620 assert_eq!(spec["options"][1]["preview"], "fn login() {}");
1621
1622 let decoded: Evt = serde_json::from_value(json).expect("deserialize question pause");
1623 let Evt::TurnPause {
1624 reason: super::TurnPauseReason::Question { tool_use_id, questions },
1625 ..
1626 } = decoded
1627 else {
1628 panic!("expected Question pause");
1629 };
1630 assert_eq!(tool_use_id, "toolu_1");
1631 assert_eq!(questions.len(), 1);
1632 assert!(!questions[0].multi_select);
1633
1634 let sparse: super::QuestionSpec = serde_json::from_value(serde_json::json!({
1636 "header": "Scope",
1637 "question": "How broad?",
1638 "options": [],
1639 }))
1640 .unwrap();
1641 assert!(!sparse.multi_select);
1642 }
1643
1644 #[test]
1645 fn question_response_serde_roundtrip() {
1646 let turn_id = Id::new("op");
1647 let op = Op::QuestionResponse {
1648 turn_id,
1649 tool_use_id: "toolu_1".to_string(),
1650 reply: super::QuestionReply::Answered(vec![super::QuestionAnswer {
1651 selected: vec!["JWT".to_string(), "Sessions".to_string()],
1652 note: None,
1653 }]),
1654 };
1655 let json = serde_json::to_string(&op).expect("serialize QuestionResponse");
1656 let decoded: Op = serde_json::from_str(&json).expect("deserialize QuestionResponse");
1657 assert!(matches!(
1658 decoded,
1659 Op::QuestionResponse { tool_use_id, reply: super::QuestionReply::Answered(answers), .. }
1660 if tool_use_id == "toolu_1" && answers[0].selected.len() == 2
1661 ));
1662
1663 for (reply, expected) in [
1666 (super::QuestionReply::Dismissed, serde_json::json!("Dismissed")),
1667 (super::QuestionReply::Discuss { message: None }, serde_json::json!({ "Discuss": {} })),
1668 (
1669 super::QuestionReply::Discuss { message: Some("later".to_string()) },
1670 serde_json::json!({ "Discuss": { "message": "later" } }),
1671 ),
1672 ] {
1673 let json = serde_json::to_value(&reply).expect("serialize reply");
1674 assert_eq!(json, expected);
1675 let decoded: super::QuestionReply =
1676 serde_json::from_value(json).expect("deserialize reply");
1677 assert_eq!(decoded, reply);
1678 }
1679 }
1680
1681 #[test]
1682 fn usage_adds_cache_fields_without_overflowing() {
1683 let mut usage = Usage {
1684 input_tokens: 10,
1685 output_tokens: 20,
1686 cache_read_tokens: Some(3),
1687 cache_creation_tokens: None,
1688 };
1689 usage += Usage {
1690 input_tokens: 5,
1691 output_tokens: 6,
1692 cache_read_tokens: Some(4),
1693 cache_creation_tokens: Some(8),
1694 };
1695
1696 assert_eq!(usage.input_tokens, 15);
1697 assert_eq!(usage.output_tokens, 26);
1698 assert_eq!(usage.total(), 41);
1699 assert_eq!(usage.cache_read_tokens, Some(7));
1700 assert_eq!(usage.cache_creation_tokens, Some(8));
1701 }
1702}