1use serde::de::Deserializer;
9use serde::ser::Serializer;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::content::ContentBlock;
14use crate::runtime::TerminalReason;
15
16pub type Extras = serde_json::Map<String, Value>;
21
22pub fn names_a_dispatch(parent_tool_use_id: Option<&str>) -> bool {
26 parent_tool_use_id.is_some_and(|parent| !parent.trim().is_empty())
27}
28
29pub fn ran_under_a_dispatch(message: &Message) -> bool {
33 match message {
34 Message::Assistant { parent_tool_use_id, .. }
35 | Message::User { parent_tool_use_id, .. } => {
36 names_a_dispatch(parent_tool_use_id.as_deref())
37 }
38 _ => false,
39 }
40}
41
42#[derive(Debug, Clone, PartialEq)]
48pub enum Message {
49 Assistant {
51 message: AssistantEnvelope,
53 session_id: String,
55 parent_tool_use_id: Option<String>,
57 error: Option<AssistantMessageError>,
62 uuid: Option<String>,
65 timestamp: Option<String>,
68 extras: Extras,
71 },
72
73 User {
75 message: UserEnvelope,
77 session_id: String,
79 parent_tool_use_id: Option<String>,
81 uuid: Option<String>,
85 tool_use_result: Option<Value>,
90 timestamp: Option<String>,
93 synthetic: bool,
101 extras: Extras,
104 },
105
106 System {
110 subtype: String,
112 session_id: Option<String>,
114 data: Value,
116 },
117
118 TaskStarted {
121 task_id: String,
123 description: String,
125 uuid: String,
127 session_id: String,
129 tool_use_id: Option<String>,
131 task_type: Option<String>,
133 extras: Extras,
136 },
137
138 TaskUpdated {
150 task_id: String,
153 patch: TaskUpdatePatch,
156 uuid: String,
158 session_id: String,
160 extras: Extras,
163 },
164
165 TaskProgress {
169 task_id: String,
171 description: String,
173 usage: TaskUsage,
175 uuid: String,
177 session_id: String,
179 tool_use_id: Option<String>,
181 last_tool_name: Option<String>,
183 workflow_progress: Vec<WorkflowProgressEvent>,
189 extras: Extras,
192 },
193
194 TaskNotification {
209 task_id: String,
211 status: TaskNotificationStatus,
213 output_file: String,
217 summary: String,
219 uuid: String,
221 session_id: String,
223 tool_use_id: Option<String>,
225 usage: Option<TaskUsage>,
227 extras: Extras,
230 },
231
232 ToolProgress {
241 tool_use_id: String,
243 tool_name: String,
245 elapsed_time_seconds: f64,
247 heartbeat: bool,
249 parent_tool_use_id: Option<String>,
252 session_id: Option<String>,
254 uuid: Option<String>,
256 extras: Extras,
259 },
260
261 ThinkingTokens {
270 estimated_tokens: u64,
275 estimated_tokens_delta: i64,
280 uuid: String,
282 session_id: String,
284 extras: Extras,
287 },
288
289 TurnDuration {
293 ms: u64,
296 message_count: Option<u64>,
300 parent_tool_use_id: Option<String>,
302 session_id: String,
304 uuid: String,
306 extras: Extras,
309 },
310
311 StopHookSummary {
317 actions: u32,
320 hook_infos: Vec<StopHookInfo>,
323 hook_errors: Vec<String>,
327 has_output: bool,
331 level: String,
333 prevented_continuation: bool,
336 stop_reason: String,
338 tool_use_id: String,
341 parent_tool_use_id: Option<String>,
343 session_id: String,
345 uuid: String,
347 extras: Extras,
350 },
351
352 BackgroundTasksChanged {
356 tasks: Vec<Value>,
359 uuid: String,
361 session_id: String,
363 extras: Extras,
366 },
367
368 CommandsChanged {
371 commands: Vec<Value>,
373 uuid: String,
375 session_id: String,
377 extras: Extras,
380 },
381
382 HookStarted {
384 hook_id: String,
386 hook_name: String,
388 hook_event: String,
390 uuid: String,
392 session_id: String,
394 extras: Extras,
397 },
398
399 HookResponse {
401 hook_id: String,
403 hook_name: String,
405 hook_event: String,
407 outcome: String,
409 exit_code: i64,
411 output: String,
413 stdout: String,
415 stderr: String,
417 uuid: String,
419 session_id: String,
421 extras: Extras,
424 },
425
426 HookProgress {
429 hook_id: String,
431 hook_name: String,
433 hook_event: String,
435 stdout: String,
437 stderr: String,
439 output: String,
441 uuid: String,
443 session_id: String,
445 extras: Extras,
448 },
449
450 Notification {
453 key: Option<String>,
455 text: String,
457 priority: Option<String>,
459 uuid: String,
461 session_id: String,
463 extras: Extras,
466 },
467
468 PermissionDenied {
471 tool_name: String,
473 tool_use_id: String,
475 decision_reason_type: Option<String>,
479 decision_reason: Option<String>,
483 message: String,
485 uuid: String,
487 session_id: String,
489 extras: Extras,
492 },
493
494 CompactBoundary {
502 trigger: String,
505 pre_tokens: u64,
507 post_tokens: u64,
509 uuid: String,
511 session_id: String,
513 metadata_extras: Extras,
517 extras: Extras,
520 },
521
522 VcsStateChanged {
529 kind: String,
534 branch: Option<String>,
538 cwd: String,
541 uuid: String,
543 session_id: String,
545 extras: Extras,
548 },
549
550 CodeChangePublished {
557 provider: String,
561 url: String,
563 repo: String,
565 identifier: String,
567 action: Option<String>,
573 branch: Option<String>,
576 uuid: String,
578 session_id: String,
580 extras: Extras,
583 },
584
585 RateLimitEvent {
590 rate_limit_info: RateLimitInfo,
592 uuid: String,
594 session_id: String,
596 extras: Extras,
599 },
600
601 CommandLifecycle {
613 command_uuid: String,
616 state: String,
618 uuid: String,
620 session_id: String,
622 extras: Extras,
625 },
626
627 Result {
634 subtype: String,
636 session_id: String,
638 is_error: bool,
640 num_turns: u64,
642 duration_ms: u64,
644 duration_api_ms: u64,
646 stop_reason: Option<String>,
649 total_cost_usd: Option<f64>,
652 usage: Option<Usage>,
655 result: Option<String>,
658 structured_output: Option<Value>,
662 model_usage: Option<Value>,
665 permission_denials: Option<Vec<Value>>,
668 errors: Option<Vec<String>>,
670 uuid: Option<String>,
672 terminal_reason: Option<TerminalReason>,
677 extras: Extras,
680 },
681
682 StreamEvent {
690 uuid: String,
692 session_id: String,
694 event: Value,
696 parent_tool_use_id: Option<String>,
698 extras: Extras,
701 },
702
703 Error {
711 error: String,
713 extras: Extras,
716 },
717
718 Unknown {
733 type_str: String,
735 raw: Value,
738 },
739}
740
741impl Message {
742 pub fn session_id(&self) -> Option<&str> {
755 match self {
756 Message::Assistant { session_id, .. }
757 | Message::User { session_id, .. }
758 | Message::TaskStarted { session_id, .. }
759 | Message::TaskUpdated { session_id, .. }
760 | Message::TaskProgress { session_id, .. }
761 | Message::TaskNotification { session_id, .. }
762 | Message::ThinkingTokens { session_id, .. }
763 | Message::TurnDuration { session_id, .. }
764 | Message::StopHookSummary { session_id, .. }
765 | Message::BackgroundTasksChanged { session_id, .. }
766 | Message::CommandsChanged { session_id, .. }
767 | Message::HookStarted { session_id, .. }
768 | Message::HookProgress { session_id, .. }
769 | Message::PermissionDenied { session_id, .. }
770 | Message::HookResponse { session_id, .. }
771 | Message::Notification { session_id, .. }
772 | Message::CompactBoundary { session_id, .. }
773 | Message::VcsStateChanged { session_id, .. }
774 | Message::CodeChangePublished { session_id, .. }
775 | Message::Result { session_id, .. }
776 | Message::CommandLifecycle { session_id, .. }
777 | Message::StreamEvent { session_id, .. } => Some(session_id.as_str()),
778 Message::System { session_id, .. } | Message::ToolProgress { session_id, .. } => {
779 session_id.as_deref()
780 }
781 Message::RateLimitEvent { .. } | Message::Error { .. } | Message::Unknown { .. } => {
782 None
783 }
784 }
785 }
786
787 pub fn display_only_user(text: String, uuid: String) -> Self {
796 Self::display_only_user_with_blocks(text, Vec::new(), uuid)
797 }
798
799 pub fn display_only_user_with_blocks(
804 text: String,
805 images: Vec<ContentBlock>,
806 uuid: String,
807 ) -> Self {
808 let mut content = images;
809 content.push(ContentBlock::Text { text, extras: Extras::new() });
810 Message::User {
811 message: UserEnvelope { role: "user".to_owned(), content, extras: Extras::new() },
812 extras: Extras::new(),
813 session_id: String::new(),
814 parent_tool_use_id: None,
815 uuid: Some(uuid),
816 tool_use_result: None,
817 timestamp: None,
818 synthetic: false,
819 }
820 }
821
822 pub fn stamp_if_absent(&mut self, stamp: &str) {
834 match self {
835 Message::Assistant { timestamp, .. } | Message::User { timestamp, .. } => {
836 if timestamp.is_none() {
837 *timestamp = Some(stamp.to_owned());
838 }
839 }
840 Message::System { data, .. } | Message::Unknown { raw: data, .. } => {
841 if let Value::Object(map) = data
842 && !map.contains_key("timestamp")
843 {
844 map.insert("timestamp".to_owned(), Value::String(stamp.to_owned()));
845 }
846 }
847 Message::TaskStarted { extras, .. }
850 | Message::TaskUpdated { extras, .. }
851 | Message::TaskProgress { extras, .. }
852 | Message::TaskNotification { extras, .. }
853 | Message::ToolProgress { extras, .. }
854 | Message::ThinkingTokens { extras, .. }
855 | Message::TurnDuration { extras, .. }
856 | Message::StopHookSummary { extras, .. }
857 | Message::BackgroundTasksChanged { extras, .. }
858 | Message::CommandsChanged { extras, .. }
859 | Message::HookStarted { extras, .. }
860 | Message::HookResponse { extras, .. }
861 | Message::HookProgress { extras, .. }
862 | Message::Notification { extras, .. }
863 | Message::PermissionDenied { extras, .. }
864 | Message::CompactBoundary { extras, .. }
865 | Message::VcsStateChanged { extras, .. }
866 | Message::CodeChangePublished { extras, .. }
867 | Message::RateLimitEvent { extras, .. }
868 | Message::CommandLifecycle { extras, .. }
869 | Message::Result { extras, .. }
870 | Message::StreamEvent { extras, .. }
871 | Message::Error { extras, .. } => {
872 if !extras.contains_key("timestamp") {
873 extras.insert("timestamp".to_owned(), Value::String(stamp.to_owned()));
874 }
875 }
876 }
877 }
878}
879
880#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
882pub struct AssistantEnvelope {
883 pub id: String,
885 pub role: String,
887 pub model: String,
889 pub content: Vec<ContentBlock>,
891 #[serde(default)]
893 pub stop_reason: Option<StopReason>,
894 #[serde(default)]
896 pub stop_sequence: Option<String>,
897 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub usage: Option<Usage>,
901 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
904 pub extras: Extras,
905}
906
907#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
910#[serde(rename_all = "snake_case")]
911pub enum AssistantMessageError {
912 AuthenticationFailed,
914 BillingError,
916 RateLimit,
918 InvalidRequest,
920 ServerError,
922 #[serde(other)]
924 Unknown,
925}
926
927#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
929pub struct UserEnvelope {
930 pub role: String,
932 #[serde(deserialize_with = "deserialize_user_content")]
938 pub content: Vec<ContentBlock>,
939 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
941 pub extras: Extras,
942}
943
944fn deserialize_user_content<'de, D>(de: D) -> Result<Vec<ContentBlock>, D::Error>
945where
946 D: serde::Deserializer<'de>,
947{
948 use serde::de::Error as _;
949 let value = Value::deserialize(de)?;
950 match value {
951 Value::String(s) => Ok(vec![ContentBlock::Text { text: s, extras: Extras::new() }]),
952 Value::Array(_) => serde_json::from_value(value).map_err(D::Error::custom),
953 other => {
954 Err(D::Error::custom(format!("user message content must be str or list, got: {other}")))
955 }
956 }
957}
958
959#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
961#[serde(rename_all = "snake_case")]
962pub enum StopReason {
963 EndTurn,
965 MaxTokens,
967 StopSequence,
969 ToolUse,
971 #[serde(other)]
973 Unknown,
974}
975
976#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
979#[serde(rename_all = "snake_case")]
980pub enum RateLimitStatus {
981 Allowed,
983 AllowedWarning,
985 Rejected,
987 #[serde(other)]
989 Unknown,
990}
991
992#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
994#[serde(rename_all = "snake_case")]
995pub enum RateLimitType {
996 FiveHour,
998 SevenDay,
1000 SevenDayOpus,
1002 SevenDaySonnet,
1004 Overage,
1006 #[serde(other)]
1008 Unknown,
1009}
1010
1011#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1018pub struct RateLimitInfo {
1019 pub status: RateLimitStatus,
1021 #[serde(default, rename = "resetsAt", skip_serializing_if = "Option::is_none")]
1023 pub resets_at: Option<i64>,
1024 #[serde(default, rename = "rateLimitType", skip_serializing_if = "Option::is_none")]
1026 pub rate_limit_type: Option<RateLimitType>,
1027 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub utilization: Option<f64>,
1030 #[serde(default, rename = "overageStatus", skip_serializing_if = "Option::is_none")]
1032 pub overage_status: Option<RateLimitStatus>,
1033 #[serde(default, rename = "overageResetsAt", skip_serializing_if = "Option::is_none")]
1035 pub overage_resets_at: Option<i64>,
1036 #[serde(default, rename = "overageDisabledReason", skip_serializing_if = "Option::is_none")]
1038 pub overage_disabled_reason: Option<String>,
1039 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1043 pub raw: serde_json::Map<String, serde_json::Value>,
1044}
1045
1046#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1048pub struct Usage {
1049 #[serde(default, deserialize_with = "null_as_zero")]
1051 pub input_tokens: u64,
1052 #[serde(default, deserialize_with = "null_as_zero")]
1054 pub output_tokens: u64,
1055 #[serde(default, deserialize_with = "null_as_zero")]
1057 pub cache_creation_input_tokens: u64,
1058 #[serde(default, deserialize_with = "null_as_zero")]
1060 pub cache_read_input_tokens: u64,
1061 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1064 pub extras: Extras,
1065}
1066
1067fn null_as_zero<'de, D>(deserializer: D) -> Result<u64, D::Error>
1076where
1077 D: serde::Deserializer<'de>,
1078{
1079 Ok(Option::<u64>::deserialize(deserializer)?.unwrap_or(0))
1080}
1081
1082#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1086pub struct StopHookInfo {
1087 pub command: String,
1089 #[serde(rename = "durationMs", default, skip_serializing_if = "Option::is_none")]
1092 pub duration_ms: Option<u64>,
1093 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1095 pub extras: Extras,
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1102pub struct TaskUsage {
1103 pub total_tokens: u64,
1105 pub tool_uses: u64,
1107 pub duration_ms: u64,
1109 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1111 pub extras: Extras,
1112}
1113
1114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1118#[serde(rename_all = "snake_case")]
1119pub enum TaskNotificationStatus {
1120 Completed,
1122 Failed,
1124 Stopped,
1126 #[serde(other)]
1128 Unknown,
1129}
1130
1131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1138pub struct TaskUpdatePatch {
1139 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub status: Option<String>,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1145 pub end_time: Option<u64>,
1146 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1148 pub extras: Extras,
1149}
1150
1151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1181#[serde(tag = "type", rename_all = "snake_case")]
1182pub enum WorkflowProgressEvent {
1183 WorkflowPhase {
1186 index: u32,
1187 title: String,
1188 #[serde(flatten)]
1189 extras: Extras,
1190 },
1191 WorkflowAgent {
1195 index: u32,
1196 label: String,
1197 #[serde(rename = "phaseIndex", default, skip_serializing_if = "Option::is_none")]
1203 phase_index: Option<u32>,
1204 #[serde(rename = "phaseTitle", default, skip_serializing_if = "Option::is_none")]
1205 phase_title: Option<String>,
1206 state: String,
1210 #[serde(rename = "lastToolName", default, skip_serializing_if = "Option::is_none")]
1213 last_tool_name: Option<String>,
1214 #[serde(rename = "lastToolSummary", default, skip_serializing_if = "Option::is_none")]
1216 last_tool_summary: Option<String>,
1217 #[serde(rename = "resultPreview", default, skip_serializing_if = "Option::is_none")]
1221 result_preview: Option<String>,
1222 #[serde(flatten)]
1223 extras: Extras,
1224 },
1225 #[serde(other)]
1230 Other,
1231}
1232
1233impl Serialize for Message {
1257 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1258 match self {
1259 Message::Unknown { raw, .. } => raw.serialize(serializer),
1260 other => MessageRepr::from(other.clone()).serialize(serializer),
1261 }
1262 }
1263}
1264
1265impl<'de> Deserialize<'de> for Message {
1266 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1267 MessageRepr::deserialize(deserializer).map(Message::from)
1268 }
1269}
1270
1271#[derive(Debug, Clone, Serialize, Deserialize)]
1272#[serde(tag = "type", rename_all = "snake_case")]
1273enum MessageRepr {
1274 Assistant {
1275 message: AssistantEnvelope,
1276 session_id: String,
1277 #[serde(default)]
1278 parent_tool_use_id: Option<String>,
1279 #[serde(default, skip_serializing_if = "Option::is_none")]
1280 error: Option<AssistantMessageError>,
1281 #[serde(default, skip_serializing_if = "Option::is_none")]
1282 uuid: Option<String>,
1283 #[serde(default, skip_serializing_if = "Option::is_none")]
1284 timestamp: Option<String>,
1285 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1286 extras: Extras,
1287 },
1288 User {
1289 message: UserEnvelope,
1290 session_id: String,
1291 #[serde(default)]
1292 parent_tool_use_id: Option<String>,
1293 #[serde(default, skip_serializing_if = "Option::is_none")]
1294 uuid: Option<String>,
1295 #[serde(default, skip_serializing_if = "Option::is_none")]
1296 tool_use_result: Option<Value>,
1297 #[serde(default, skip_serializing_if = "Option::is_none")]
1298 timestamp: Option<String>,
1299 #[serde(default, rename = "isSynthetic", skip_serializing_if = "std::ops::Not::not")]
1303 synthetic: bool,
1304 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1305 extras: Extras,
1306 },
1307 System(SystemRepr),
1308 RateLimitEvent {
1309 rate_limit_info: RateLimitInfo,
1310 uuid: String,
1311 session_id: String,
1312 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1313 extras: Extras,
1314 },
1315 CommandLifecycle {
1316 command_uuid: String,
1317 state: String,
1318 uuid: String,
1319 session_id: String,
1320 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1321 extras: Extras,
1322 },
1323 Result {
1324 subtype: String,
1325 session_id: String,
1326 is_error: bool,
1327 num_turns: u64,
1328 duration_ms: u64,
1329 duration_api_ms: u64,
1330 #[serde(default, skip_serializing_if = "Option::is_none")]
1331 stop_reason: Option<String>,
1332 #[serde(default, skip_serializing_if = "Option::is_none")]
1333 total_cost_usd: Option<f64>,
1334 #[serde(default, skip_serializing_if = "Option::is_none")]
1335 usage: Option<Usage>,
1336 #[serde(default, skip_serializing_if = "Option::is_none")]
1337 result: Option<String>,
1338 #[serde(default, skip_serializing_if = "Option::is_none")]
1339 structured_output: Option<Value>,
1340 #[serde(default, rename = "modelUsage", skip_serializing_if = "Option::is_none")]
1341 model_usage: Option<Value>,
1342 #[serde(default, skip_serializing_if = "Option::is_none")]
1343 permission_denials: Option<Vec<Value>>,
1344 #[serde(default, skip_serializing_if = "Option::is_none")]
1345 errors: Option<Vec<String>>,
1346 #[serde(default, skip_serializing_if = "Option::is_none")]
1347 uuid: Option<String>,
1348 #[serde(default, skip_serializing_if = "Option::is_none")]
1349 terminal_reason: Option<TerminalReason>,
1350 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1351 extras: Extras,
1352 },
1353 StreamEvent {
1354 uuid: String,
1355 session_id: String,
1356 event: Value,
1357 #[serde(default)]
1358 parent_tool_use_id: Option<String>,
1359 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1360 extras: Extras,
1361 },
1362 ToolProgress {
1365 tool_use_id: String,
1366 tool_name: String,
1367 elapsed_time_seconds: f64,
1368 #[serde(default)]
1369 heartbeat: bool,
1370 #[serde(default, skip_serializing_if = "Option::is_none")]
1371 parent_tool_use_id: Option<String>,
1372 #[serde(default, skip_serializing_if = "Option::is_none")]
1373 session_id: Option<String>,
1374 #[serde(default, skip_serializing_if = "Option::is_none")]
1375 uuid: Option<String>,
1376 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1377 extras: Extras,
1378 },
1379 Error {
1380 error: String,
1381 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1382 extras: Extras,
1383 },
1384}
1385
1386#[expect(clippy::large_enum_variant)]
1392#[derive(Debug, Clone, Serialize, Deserialize)]
1393#[serde(untagged)]
1394enum SystemRepr {
1395 Typed(TypedSystemRepr),
1396 Generic(GenericSystemRepr),
1397}
1398
1399#[derive(Debug, Clone, Serialize, Deserialize)]
1400#[serde(tag = "subtype", rename_all = "snake_case")]
1401enum TypedSystemRepr {
1402 TaskStarted {
1403 task_id: String,
1404 description: String,
1405 uuid: String,
1406 session_id: String,
1407 #[serde(default, skip_serializing_if = "Option::is_none")]
1408 tool_use_id: Option<String>,
1409 #[serde(default, skip_serializing_if = "Option::is_none")]
1410 task_type: Option<String>,
1411 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1412 extras: Extras,
1413 },
1414 TaskUpdated {
1415 task_id: String,
1416 #[serde(default)]
1417 patch: TaskUpdatePatch,
1418 uuid: String,
1419 session_id: String,
1420 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1421 extras: Extras,
1422 },
1423 TaskProgress {
1424 task_id: String,
1425 description: String,
1426 usage: TaskUsage,
1427 uuid: String,
1428 session_id: String,
1429 #[serde(default, skip_serializing_if = "Option::is_none")]
1430 tool_use_id: Option<String>,
1431 #[serde(default, skip_serializing_if = "Option::is_none")]
1432 last_tool_name: Option<String>,
1433 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439 workflow_progress: Vec<WorkflowProgressEvent>,
1440 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1441 extras: Extras,
1442 },
1443 TaskNotification {
1444 task_id: String,
1445 status: TaskNotificationStatus,
1446 output_file: String,
1447 summary: String,
1448 uuid: String,
1449 session_id: String,
1450 #[serde(default, skip_serializing_if = "Option::is_none")]
1451 tool_use_id: Option<String>,
1452 #[serde(default, skip_serializing_if = "Option::is_none")]
1453 usage: Option<TaskUsage>,
1454 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1455 extras: Extras,
1456 },
1457 ThinkingTokens {
1458 estimated_tokens: u64,
1459 estimated_tokens_delta: i64,
1460 uuid: String,
1461 session_id: String,
1462 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1463 extras: Extras,
1464 },
1465 TurnDuration {
1466 #[serde(rename = "durationMs")]
1467 ms: u64,
1468 #[serde(default, rename = "messageCount", skip_serializing_if = "Option::is_none")]
1469 message_count: Option<u64>,
1470 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 parent_tool_use_id: Option<String>,
1472 session_id: String,
1473 uuid: String,
1474 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1475 extras: Extras,
1476 },
1477 StopHookSummary {
1478 #[serde(rename = "hookCount")]
1479 actions: u32,
1480 #[serde(default, rename = "hookInfos")]
1481 hook_infos: Vec<StopHookInfo>,
1482 #[serde(default, rename = "hookErrors")]
1483 hook_errors: Vec<String>,
1484 #[serde(rename = "hasOutput")]
1485 has_output: bool,
1486 level: String,
1487 #[serde(rename = "preventedContinuation")]
1488 prevented_continuation: bool,
1489 #[serde(rename = "stopReason")]
1490 stop_reason: String,
1491 #[serde(rename = "toolUseID")]
1492 tool_use_id: String,
1493 #[serde(default, skip_serializing_if = "Option::is_none")]
1494 parent_tool_use_id: Option<String>,
1495 session_id: String,
1496 uuid: String,
1497 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1498 extras: Extras,
1499 },
1500 BackgroundTasksChanged {
1501 tasks: Vec<Value>,
1502 uuid: String,
1503 session_id: String,
1504 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1505 extras: Extras,
1506 },
1507 CommandsChanged {
1508 commands: Vec<Value>,
1509 uuid: String,
1510 session_id: String,
1511 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1512 extras: Extras,
1513 },
1514 HookStarted {
1515 hook_id: String,
1516 hook_name: String,
1517 hook_event: String,
1518 uuid: String,
1519 session_id: String,
1520 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1521 extras: Extras,
1522 },
1523 HookResponse {
1524 hook_id: String,
1525 hook_name: String,
1526 hook_event: String,
1527 outcome: String,
1528 exit_code: i64,
1529 output: String,
1530 stdout: String,
1531 stderr: String,
1532 uuid: String,
1533 session_id: String,
1534 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1535 extras: Extras,
1536 },
1537 HookProgress {
1538 hook_id: String,
1539 hook_name: String,
1540 hook_event: String,
1541 stdout: String,
1542 stderr: String,
1543 output: String,
1544 uuid: String,
1545 session_id: String,
1546 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1547 extras: Extras,
1548 },
1549 Notification {
1550 key: Option<String>,
1551 text: String,
1552 priority: Option<String>,
1553 uuid: String,
1554 session_id: String,
1555 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1556 extras: Extras,
1557 },
1558 PermissionDenied {
1559 tool_name: String,
1560 tool_use_id: String,
1561 #[serde(skip_serializing_if = "Option::is_none")]
1562 decision_reason_type: Option<String>,
1563 #[serde(skip_serializing_if = "Option::is_none")]
1564 decision_reason: Option<String>,
1565 message: String,
1566 uuid: String,
1567 session_id: String,
1568 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1569 extras: Extras,
1570 },
1571 CompactBoundary {
1572 compact_metadata: CompactMetadataRepr,
1573 uuid: String,
1574 session_id: String,
1575 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1576 extras: Extras,
1577 },
1578 VcsStateChanged {
1579 kind: String,
1580 #[serde(default, skip_serializing_if = "Option::is_none")]
1581 branch: Option<String>,
1582 cwd: String,
1583 uuid: String,
1584 session_id: String,
1585 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1586 extras: Extras,
1587 },
1588 CodeChangePublished {
1589 provider: String,
1590 url: String,
1591 repo: String,
1592 identifier: String,
1593 #[serde(default, skip_serializing_if = "Option::is_none")]
1594 action: Option<String>,
1595 #[serde(default, skip_serializing_if = "Option::is_none")]
1596 branch: Option<String>,
1597 uuid: String,
1598 session_id: String,
1599 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1600 extras: Extras,
1601 },
1602}
1603
1604#[derive(Debug, Clone, Serialize, Deserialize)]
1608struct CompactMetadataRepr {
1609 trigger: String,
1610 pre_tokens: u64,
1611 post_tokens: u64,
1612 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
1613 extras: Extras,
1614}
1615
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1617struct GenericSystemRepr {
1618 subtype: String,
1619 #[serde(default, skip_serializing_if = "Option::is_none")]
1620 session_id: Option<String>,
1621 #[serde(flatten)]
1622 data: Value,
1623}
1624
1625impl From<MessageRepr> for Message {
1626 fn from(repr: MessageRepr) -> Self {
1627 match repr {
1628 MessageRepr::Assistant {
1629 message,
1630 session_id,
1631 parent_tool_use_id,
1632 error,
1633 uuid,
1634 timestamp,
1635 extras,
1636 } => Message::Assistant {
1637 message,
1638 session_id,
1639 parent_tool_use_id,
1640 error,
1641 uuid,
1642 timestamp,
1643 extras,
1644 },
1645 MessageRepr::User {
1646 message,
1647 session_id,
1648 parent_tool_use_id,
1649 uuid,
1650 tool_use_result,
1651 timestamp,
1652 synthetic,
1653 extras,
1654 } => Message::User {
1655 message,
1656 session_id,
1657 parent_tool_use_id,
1658 uuid,
1659 tool_use_result,
1660 timestamp,
1661 synthetic,
1662 extras,
1663 },
1664 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskStarted {
1665 task_id,
1666 description,
1667 uuid,
1668 session_id,
1669 tool_use_id,
1670 task_type,
1671 extras,
1672 })) => Message::TaskStarted {
1673 task_id,
1674 description,
1675 uuid,
1676 session_id,
1677 tool_use_id,
1678 task_type,
1679 extras,
1680 },
1681 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskUpdated {
1682 task_id,
1683 patch,
1684 uuid,
1685 session_id,
1686 extras,
1687 })) => Message::TaskUpdated { task_id, patch, uuid, session_id, extras },
1688 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskProgress {
1689 task_id,
1690 description,
1691 usage,
1692 uuid,
1693 session_id,
1694 tool_use_id,
1695 last_tool_name,
1696 workflow_progress,
1697 extras,
1698 })) => Message::TaskProgress {
1699 task_id,
1700 description,
1701 usage,
1702 uuid,
1703 session_id,
1704 tool_use_id,
1705 last_tool_name,
1706 workflow_progress,
1707 extras,
1708 },
1709 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskNotification {
1710 task_id,
1711 status,
1712 output_file,
1713 summary,
1714 uuid,
1715 session_id,
1716 tool_use_id,
1717 usage,
1718 extras,
1719 })) => Message::TaskNotification {
1720 task_id,
1721 status,
1722 output_file,
1723 summary,
1724 uuid,
1725 session_id,
1726 tool_use_id,
1727 usage,
1728 extras,
1729 },
1730 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::ThinkingTokens {
1731 estimated_tokens,
1732 estimated_tokens_delta,
1733 uuid,
1734 session_id,
1735 extras,
1736 })) => Message::ThinkingTokens {
1737 estimated_tokens,
1738 estimated_tokens_delta,
1739 uuid,
1740 session_id,
1741 extras,
1742 },
1743 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TurnDuration {
1744 ms,
1745 message_count,
1746 parent_tool_use_id,
1747 session_id,
1748 uuid,
1749 extras,
1750 })) => Message::TurnDuration {
1751 ms,
1752 message_count,
1753 parent_tool_use_id,
1754 session_id,
1755 uuid,
1756 extras,
1757 },
1758 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::StopHookSummary {
1759 actions,
1760 hook_infos,
1761 hook_errors,
1762 has_output,
1763 level,
1764 prevented_continuation,
1765 stop_reason,
1766 tool_use_id,
1767 parent_tool_use_id,
1768 session_id,
1769 uuid,
1770 extras,
1771 })) => Message::StopHookSummary {
1772 actions,
1773 hook_infos,
1774 hook_errors,
1775 has_output,
1776 level,
1777 prevented_continuation,
1778 stop_reason,
1779 tool_use_id,
1780 parent_tool_use_id,
1781 session_id,
1782 uuid,
1783 extras,
1784 },
1785 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::BackgroundTasksChanged {
1786 tasks,
1787 uuid,
1788 session_id,
1789 extras,
1790 })) => Message::BackgroundTasksChanged { tasks, uuid, session_id, extras },
1791 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CommandsChanged {
1792 commands,
1793 uuid,
1794 session_id,
1795 extras,
1796 })) => Message::CommandsChanged { commands, uuid, session_id, extras },
1797 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookStarted {
1798 hook_id,
1799 hook_name,
1800 hook_event,
1801 uuid,
1802 session_id,
1803 extras,
1804 })) => {
1805 Message::HookStarted { hook_id, hook_name, hook_event, uuid, session_id, extras }
1806 }
1807 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookResponse {
1808 hook_id,
1809 hook_name,
1810 hook_event,
1811 outcome,
1812 exit_code,
1813 output,
1814 stdout,
1815 stderr,
1816 uuid,
1817 session_id,
1818 extras,
1819 })) => Message::HookResponse {
1820 hook_id,
1821 hook_name,
1822 hook_event,
1823 outcome,
1824 exit_code,
1825 output,
1826 stdout,
1827 stderr,
1828 uuid,
1829 session_id,
1830 extras,
1831 },
1832 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookProgress {
1833 hook_id,
1834 hook_name,
1835 hook_event,
1836 stdout,
1837 stderr,
1838 output,
1839 uuid,
1840 session_id,
1841 extras,
1842 })) => Message::HookProgress {
1843 hook_id,
1844 hook_name,
1845 hook_event,
1846 stdout,
1847 stderr,
1848 output,
1849 uuid,
1850 session_id,
1851 extras,
1852 },
1853 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::Notification {
1854 key,
1855 text,
1856 priority,
1857 uuid,
1858 session_id,
1859 extras,
1860 })) => Message::Notification { key, text, priority, uuid, session_id, extras },
1861 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::PermissionDenied {
1862 tool_name,
1863 tool_use_id,
1864 decision_reason_type,
1865 decision_reason,
1866 message,
1867 uuid,
1868 session_id,
1869 extras,
1870 })) => Message::PermissionDenied {
1871 tool_name,
1872 tool_use_id,
1873 decision_reason_type,
1874 decision_reason,
1875 message,
1876 uuid,
1877 session_id,
1878 extras,
1879 },
1880 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CompactBoundary {
1881 compact_metadata:
1882 CompactMetadataRepr { trigger, pre_tokens, post_tokens, extras: metadata_extras },
1883 uuid,
1884 session_id,
1885 extras,
1886 })) => Message::CompactBoundary {
1887 trigger,
1888 pre_tokens,
1889 post_tokens,
1890 uuid,
1891 session_id,
1892 metadata_extras,
1893 extras,
1894 },
1895 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::VcsStateChanged {
1896 kind,
1897 branch,
1898 cwd,
1899 uuid,
1900 session_id,
1901 extras,
1902 })) => Message::VcsStateChanged { kind, branch, cwd, uuid, session_id, extras },
1903 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CodeChangePublished {
1904 provider,
1905 url,
1906 repo,
1907 identifier,
1908 action,
1909 branch,
1910 uuid,
1911 session_id,
1912 extras,
1913 })) => Message::CodeChangePublished {
1914 provider,
1915 url,
1916 repo,
1917 identifier,
1918 action,
1919 branch,
1920 uuid,
1921 session_id,
1922 extras,
1923 },
1924 MessageRepr::System(SystemRepr::Generic(GenericSystemRepr {
1925 subtype,
1926 session_id,
1927 data,
1928 })) => {
1929 let mut full_data = data;
1938 if let Value::Object(map) = &mut full_data {
1939 map.insert("type".into(), Value::String("system".into()));
1940 map.insert("subtype".into(), Value::String(subtype.clone()));
1941 if let Some(sid) = &session_id {
1942 map.insert("session_id".into(), Value::String(sid.clone()));
1943 }
1944 }
1945 Message::System { subtype, session_id, data: full_data }
1946 }
1947 MessageRepr::RateLimitEvent { rate_limit_info, uuid, session_id, extras } => {
1948 Message::RateLimitEvent { rate_limit_info, uuid, session_id, extras }
1949 }
1950 MessageRepr::CommandLifecycle { command_uuid, state, uuid, session_id, extras } => {
1951 Message::CommandLifecycle { command_uuid, state, uuid, session_id, extras }
1952 }
1953 MessageRepr::Result {
1954 subtype,
1955 session_id,
1956 is_error,
1957 num_turns,
1958 duration_ms,
1959 duration_api_ms,
1960 stop_reason,
1961 total_cost_usd,
1962 usage,
1963 result,
1964 structured_output,
1965 model_usage,
1966 permission_denials,
1967 errors,
1968 uuid,
1969 terminal_reason,
1970 extras,
1971 } => Message::Result {
1972 subtype,
1973 session_id,
1974 is_error,
1975 num_turns,
1976 duration_ms,
1977 duration_api_ms,
1978 stop_reason,
1979 total_cost_usd,
1980 usage,
1981 result,
1982 structured_output,
1983 model_usage,
1984 permission_denials,
1985 errors,
1986 uuid,
1987 terminal_reason,
1988 extras,
1989 },
1990 MessageRepr::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras } => {
1991 Message::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras }
1992 }
1993 MessageRepr::ToolProgress {
1994 tool_use_id,
1995 tool_name,
1996 elapsed_time_seconds,
1997 heartbeat,
1998 parent_tool_use_id,
1999 session_id,
2000 uuid,
2001 extras,
2002 } => Message::ToolProgress {
2003 tool_use_id,
2004 tool_name,
2005 elapsed_time_seconds,
2006 heartbeat,
2007 parent_tool_use_id,
2008 session_id,
2009 uuid,
2010 extras,
2011 },
2012 MessageRepr::Error { error, extras } => Message::Error { error, extras },
2013 }
2014 }
2015}
2016
2017impl From<Message> for MessageRepr {
2018 fn from(msg: Message) -> Self {
2019 match msg {
2020 Message::Assistant {
2021 message,
2022 session_id,
2023 parent_tool_use_id,
2024 error,
2025 uuid,
2026 timestamp,
2027 extras,
2028 } => MessageRepr::Assistant {
2029 message,
2030 session_id,
2031 parent_tool_use_id,
2032 error,
2033 uuid,
2034 timestamp,
2035 extras,
2036 },
2037 Message::User {
2038 message,
2039 session_id,
2040 parent_tool_use_id,
2041 uuid,
2042 tool_use_result,
2043 timestamp,
2044 synthetic,
2045 extras,
2046 } => MessageRepr::User {
2047 message,
2048 session_id,
2049 parent_tool_use_id,
2050 uuid,
2051 tool_use_result,
2052 timestamp,
2053 synthetic,
2054 extras,
2055 },
2056 Message::System { subtype, session_id, data } => {
2057 let mut flat = data;
2063 if let Value::Object(map) = &mut flat {
2064 map.remove("type");
2065 map.remove("subtype");
2066 map.remove("session_id");
2067 }
2068 MessageRepr::System(SystemRepr::Generic(GenericSystemRepr {
2069 subtype,
2070 session_id,
2071 data: flat,
2072 }))
2073 }
2074 Message::TaskStarted {
2075 task_id,
2076 description,
2077 uuid,
2078 session_id,
2079 tool_use_id,
2080 task_type,
2081 extras,
2082 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskStarted {
2083 task_id,
2084 description,
2085 uuid,
2086 session_id,
2087 tool_use_id,
2088 task_type,
2089 extras,
2090 })),
2091 Message::TaskUpdated { task_id, patch, uuid, session_id, extras } => {
2092 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskUpdated {
2093 task_id,
2094 patch,
2095 uuid,
2096 session_id,
2097 extras,
2098 }))
2099 }
2100 Message::TaskProgress {
2101 task_id,
2102 description,
2103 usage,
2104 uuid,
2105 session_id,
2106 tool_use_id,
2107 last_tool_name,
2108 workflow_progress,
2109 extras,
2110 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskProgress {
2111 task_id,
2112 description,
2113 usage,
2114 uuid,
2115 session_id,
2116 tool_use_id,
2117 last_tool_name,
2118 workflow_progress,
2119 extras,
2120 })),
2121 Message::TaskNotification {
2122 task_id,
2123 status,
2124 output_file,
2125 summary,
2126 uuid,
2127 session_id,
2128 tool_use_id,
2129 usage,
2130 extras,
2131 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TaskNotification {
2132 task_id,
2133 status,
2134 output_file,
2135 summary,
2136 uuid,
2137 session_id,
2138 tool_use_id,
2139 usage,
2140 extras,
2141 })),
2142 Message::ThinkingTokens {
2143 estimated_tokens,
2144 estimated_tokens_delta,
2145 uuid,
2146 session_id,
2147 extras,
2148 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::ThinkingTokens {
2149 estimated_tokens,
2150 estimated_tokens_delta,
2151 uuid,
2152 session_id,
2153 extras,
2154 })),
2155 Message::TurnDuration {
2156 ms,
2157 message_count,
2158 parent_tool_use_id,
2159 session_id,
2160 uuid,
2161 extras,
2162 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::TurnDuration {
2163 ms,
2164 message_count,
2165 parent_tool_use_id,
2166 session_id,
2167 uuid,
2168 extras,
2169 })),
2170 Message::StopHookSummary {
2171 actions,
2172 hook_infos,
2173 hook_errors,
2174 has_output,
2175 level,
2176 prevented_continuation,
2177 stop_reason,
2178 tool_use_id,
2179 parent_tool_use_id,
2180 session_id,
2181 uuid,
2182 extras,
2183 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::StopHookSummary {
2184 actions,
2185 hook_infos,
2186 hook_errors,
2187 has_output,
2188 level,
2189 prevented_continuation,
2190 stop_reason,
2191 tool_use_id,
2192 parent_tool_use_id,
2193 session_id,
2194 uuid,
2195 extras,
2196 })),
2197 Message::BackgroundTasksChanged { tasks, uuid, session_id, extras } => {
2198 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::BackgroundTasksChanged {
2199 tasks,
2200 uuid,
2201 session_id,
2202 extras,
2203 }))
2204 }
2205 Message::CommandsChanged { commands, uuid, session_id, extras } => {
2206 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CommandsChanged {
2207 commands,
2208 uuid,
2209 session_id,
2210 extras,
2211 }))
2212 }
2213 Message::HookStarted { hook_id, hook_name, hook_event, uuid, session_id, extras } => {
2214 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookStarted {
2215 hook_id,
2216 hook_name,
2217 hook_event,
2218 uuid,
2219 session_id,
2220 extras,
2221 }))
2222 }
2223 Message::HookResponse {
2224 hook_id,
2225 hook_name,
2226 hook_event,
2227 outcome,
2228 exit_code,
2229 output,
2230 stdout,
2231 stderr,
2232 uuid,
2233 session_id,
2234 extras,
2235 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookResponse {
2236 hook_id,
2237 hook_name,
2238 hook_event,
2239 outcome,
2240 exit_code,
2241 output,
2242 stdout,
2243 stderr,
2244 uuid,
2245 session_id,
2246 extras,
2247 })),
2248 Message::HookProgress {
2249 hook_id,
2250 hook_name,
2251 hook_event,
2252 stdout,
2253 stderr,
2254 output,
2255 uuid,
2256 session_id,
2257 extras,
2258 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::HookProgress {
2259 hook_id,
2260 hook_name,
2261 hook_event,
2262 stdout,
2263 stderr,
2264 output,
2265 uuid,
2266 session_id,
2267 extras,
2268 })),
2269 Message::Notification { key, text, priority, uuid, session_id, extras } => {
2270 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::Notification {
2271 key,
2272 text,
2273 priority,
2274 uuid,
2275 session_id,
2276 extras,
2277 }))
2278 }
2279 Message::PermissionDenied {
2280 tool_name,
2281 tool_use_id,
2282 decision_reason_type,
2283 decision_reason,
2284 message,
2285 uuid,
2286 session_id,
2287 extras,
2288 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::PermissionDenied {
2289 tool_name,
2290 tool_use_id,
2291 decision_reason_type,
2292 decision_reason,
2293 message,
2294 uuid,
2295 session_id,
2296 extras,
2297 })),
2298 Message::CompactBoundary {
2299 trigger,
2300 pre_tokens,
2301 post_tokens,
2302 uuid,
2303 session_id,
2304 metadata_extras,
2305 extras,
2306 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CompactBoundary {
2307 compact_metadata: CompactMetadataRepr {
2308 trigger,
2309 pre_tokens,
2310 post_tokens,
2311 extras: metadata_extras,
2312 },
2313 uuid,
2314 session_id,
2315 extras,
2316 })),
2317 Message::VcsStateChanged { kind, branch, cwd, uuid, session_id, extras } => {
2318 MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::VcsStateChanged {
2319 kind,
2320 branch,
2321 cwd,
2322 uuid,
2323 session_id,
2324 extras,
2325 }))
2326 }
2327 Message::CodeChangePublished {
2328 provider,
2329 url,
2330 repo,
2331 identifier,
2332 action,
2333 branch,
2334 uuid,
2335 session_id,
2336 extras,
2337 } => MessageRepr::System(SystemRepr::Typed(TypedSystemRepr::CodeChangePublished {
2338 provider,
2339 url,
2340 repo,
2341 identifier,
2342 action,
2343 branch,
2344 uuid,
2345 session_id,
2346 extras,
2347 })),
2348 Message::RateLimitEvent { rate_limit_info, uuid, session_id, extras } => {
2349 MessageRepr::RateLimitEvent { rate_limit_info, uuid, session_id, extras }
2350 }
2351 Message::CommandLifecycle { command_uuid, state, uuid, session_id, extras } => {
2352 MessageRepr::CommandLifecycle { command_uuid, state, uuid, session_id, extras }
2353 }
2354 Message::Result {
2355 subtype,
2356 session_id,
2357 is_error,
2358 num_turns,
2359 duration_ms,
2360 duration_api_ms,
2361 stop_reason,
2362 total_cost_usd,
2363 usage,
2364 result,
2365 structured_output,
2366 model_usage,
2367 permission_denials,
2368 errors,
2369 uuid,
2370 terminal_reason,
2371 extras,
2372 } => MessageRepr::Result {
2373 subtype,
2374 session_id,
2375 is_error,
2376 num_turns,
2377 duration_ms,
2378 duration_api_ms,
2379 stop_reason,
2380 total_cost_usd,
2381 usage,
2382 result,
2383 structured_output,
2384 model_usage,
2385 permission_denials,
2386 errors,
2387 uuid,
2388 terminal_reason,
2389 extras,
2390 },
2391 Message::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras } => {
2392 MessageRepr::StreamEvent { uuid, session_id, event, parent_tool_use_id, extras }
2393 }
2394 Message::ToolProgress {
2395 tool_use_id,
2396 tool_name,
2397 elapsed_time_seconds,
2398 heartbeat,
2399 parent_tool_use_id,
2400 session_id,
2401 uuid,
2402 extras,
2403 } => MessageRepr::ToolProgress {
2404 tool_use_id,
2405 tool_name,
2406 elapsed_time_seconds,
2407 heartbeat,
2408 parent_tool_use_id,
2409 session_id,
2410 uuid,
2411 extras,
2412 },
2413 Message::Error { error, extras } => MessageRepr::Error { error, extras },
2414 Message::Unknown { type_str, .. } => MessageRepr::Error {
2419 error: format!(
2420 "Message::Unknown {{ type_str: {type_str:?} }} \
2421 cannot be encoded via MessageRepr"
2422 ),
2423 extras: Extras::new(),
2424 },
2425 }
2426 }
2427}
2428
2429#[cfg(test)]
2430mod tests_result_message_fields {
2431 #[allow(unused_imports)]
2433 use super::*;
2434
2435 use crate::Message;
2436 use serde_json::json;
2437
2438 #[test]
2442 fn minimal_result_parses_without_cost_or_usage() {
2443 let raw = json!({
2444 "type": "result",
2445 "subtype": "success",
2446 "duration_ms": 10,
2447 "duration_api_ms": 8,
2448 "is_error": false,
2449 "num_turns": 1,
2450 "session_id": "sess-min",
2451 });
2452 let msg: Message = serde_json::from_value(raw).expect("parse");
2453 match msg {
2454 Message::Result {
2455 total_cost_usd,
2456 usage,
2457 stop_reason,
2458 result,
2459 structured_output,
2460 model_usage,
2461 permission_denials,
2462 errors,
2463 uuid,
2464 ..
2465 } => {
2466 assert!(total_cost_usd.is_none());
2467 assert!(usage.is_none());
2468 assert!(stop_reason.is_none());
2469 assert!(result.is_none());
2470 assert!(structured_output.is_none());
2471 assert!(model_usage.is_none());
2472 assert!(permission_denials.is_none());
2473 assert!(errors.is_none());
2474 assert!(uuid.is_none());
2475 }
2476 other => panic!("expected Result, got {other:?}"),
2477 }
2478 }
2479
2480 #[test]
2484 fn full_result_parses_and_surfaces_every_field() {
2485 let raw = json!({
2486 "type": "result",
2487 "subtype": "success",
2488 "duration_ms": 5000,
2489 "duration_api_ms": 4200,
2490 "is_error": false,
2491 "num_turns": 7,
2492 "session_id": "sess-full",
2493 "stop_reason": "end_turn",
2494 "total_cost_usd": 0.123,
2495 "usage": {
2496 "input_tokens": 100,
2497 "output_tokens": 50,
2498 "cache_creation_input_tokens": 0,
2499 "cache_read_input_tokens": 0
2500 },
2501 "result": "hello world",
2502 "structured_output": { "answer": 42 },
2503 "modelUsage": {
2504 "claude-sonnet-4-6": { "input_tokens": 60, "output_tokens": 30 }
2505 },
2506 "permission_denials": [
2507 { "tool_name": "Bash", "reason": "dry run" }
2508 ],
2509 "errors": ["warning: slow"],
2510 "uuid": "res-1"
2511 });
2512 let msg: Message = serde_json::from_value(raw).expect("parse");
2513 let Message::Result {
2514 stop_reason,
2515 total_cost_usd,
2516 usage,
2517 result,
2518 structured_output,
2519 model_usage,
2520 permission_denials,
2521 errors,
2522 uuid,
2523 ..
2524 } = msg
2525 else {
2526 panic!("expected Result");
2527 };
2528 assert_eq!(stop_reason.as_deref(), Some("end_turn"));
2529 assert_eq!(total_cost_usd, Some(0.123));
2530 assert!(usage.is_some());
2531 assert_eq!(result.as_deref(), Some("hello world"));
2532 assert_eq!(structured_output, Some(json!({ "answer": 42 })));
2533 assert!(model_usage.is_some(), "modelUsage should decode");
2534 assert_eq!(model_usage.as_ref().unwrap()["claude-sonnet-4-6"]["input_tokens"], 60);
2535 assert_eq!(
2536 permission_denials.as_deref().map(<[_]>::len),
2537 Some(1),
2538 "permission_denials should surface"
2539 );
2540 assert_eq!(errors.as_deref(), Some(vec!["warning: slow".to_string()]).as_deref());
2541 assert_eq!(uuid.as_deref(), Some("res-1"));
2542 }
2543
2544 #[test]
2548 fn result_model_usage_roundtrips_as_camel_case() {
2549 let raw = json!({
2550 "type": "result",
2551 "subtype": "success",
2552 "duration_ms": 1,
2553 "duration_api_ms": 1,
2554 "is_error": false,
2555 "num_turns": 1,
2556 "session_id": "sess-rt",
2557 "modelUsage": { "claude-sonnet-4-6": { "input_tokens": 1 } }
2558 });
2559 let msg: Message = serde_json::from_value(raw.clone()).expect("parse");
2560 let re = serde_json::to_value(&msg).expect("serialize");
2561 assert_eq!(
2562 re["modelUsage"]["claude-sonnet-4-6"]["input_tokens"], 1,
2563 "must preserve camelCase on the way out"
2564 );
2565 assert!(re.get("model_usage").is_none(), "snake_case key must NOT leak");
2566 }
2567}
2568
2569#[cfg(test)]
2570mod tests_message_extras {
2571 #[allow(unused_imports)]
2573 use super::*;
2574
2575 use crate::{AssistantMessageError, Message};
2576 use serde_json::json;
2577
2578 #[test]
2583 fn usage_counters_decode_when_the_backend_sends_null() {
2584 let measured = serde_json::json!({
2585 "input_tokens": 13,
2586 "output_tokens": 24,
2587 "output_tokens_details": { "thinking_tokens": 24 },
2588 "cache_creation_input_tokens": null,
2589 "cache_read_input_tokens": 0,
2590 "cache_creation": null,
2591 "inference_geo": null,
2592 "server_tool_use": null,
2593 "service_tier": null,
2594 "speed": "standard",
2595 "cost": 1.395e-05,
2596 "is_byok": false
2597 });
2598
2599 let usage: crate::Usage =
2600 serde_json::from_value(measured).expect("a null cache counter must not fail the frame");
2601
2602 assert_eq!(usage.cache_creation_input_tokens, 0, "a null cache counter reads as zero");
2603 assert_eq!(usage.input_tokens, 13, "and the counters beside it still decode");
2604 assert_eq!(usage.output_tokens, 24, "and the counters beside it still decode");
2605 }
2606
2607 #[test]
2608 fn assistant_error_enum_wire_names() {
2609 for (variant, wire) in [
2611 (AssistantMessageError::AuthenticationFailed, "authentication_failed"),
2612 (AssistantMessageError::BillingError, "billing_error"),
2613 (AssistantMessageError::RateLimit, "rate_limit"),
2614 (AssistantMessageError::InvalidRequest, "invalid_request"),
2615 (AssistantMessageError::ServerError, "server_error"),
2616 (AssistantMessageError::Unknown, "unknown"),
2617 ] {
2618 let encoded = serde_json::to_value(variant).expect("serialize");
2619 assert_eq!(encoded, json!(wire), "{variant:?} must wire as '{wire}'");
2620 let decoded: AssistantMessageError =
2621 serde_json::from_value(json!(wire)).expect("deserialize");
2622 assert_eq!(decoded, variant);
2623 }
2624 }
2625
2626 #[test]
2627 fn assistant_frame_decodes_error_and_uuid_outer_fields() {
2628 let raw = json!({
2629 "type": "assistant",
2630 "session_id": "sess-err",
2631 "uuid": "asst-uuid-1",
2632 "error": "rate_limit",
2633 "message": {
2634 "id": "msg_01",
2635 "role": "assistant",
2636 "model": "claude-opus-4-5",
2637 "content": [{"type": "text", "text": "throttled"}],
2638 "usage": {
2639 "input_tokens": 0,
2640 "output_tokens": 0,
2641 "cache_creation_input_tokens": 0,
2642 "cache_read_input_tokens": 0
2643 }
2644 }
2645 });
2646 let msg: Message = serde_json::from_value(raw).expect("parse");
2647 match msg {
2648 Message::Assistant { error, uuid, .. } => {
2649 assert_eq!(error, Some(AssistantMessageError::RateLimit));
2650 assert_eq!(uuid.as_deref(), Some("asst-uuid-1"));
2651 }
2652 other => panic!("expected Assistant, got {other:?}"),
2653 }
2654 }
2655
2656 #[test]
2657 fn assistant_frame_without_usage_now_parses() {
2658 let raw = json!({
2662 "type": "assistant",
2663 "session_id": "sess",
2664 "message": {
2665 "id": "msg_err",
2666 "role": "assistant",
2667 "model": "claude-opus-4-5",
2668 "content": []
2669 }
2670 });
2671 let msg: Message = serde_json::from_value(raw).expect("parse");
2672 match msg {
2673 Message::Assistant { message, .. } => {
2674 assert!(message.usage.is_none());
2675 }
2676 other => panic!("expected Assistant, got {other:?}"),
2677 }
2678 }
2679
2680 #[test]
2681 fn user_frame_decodes_uuid_and_tool_use_result() {
2682 let raw = json!({
2683 "type": "user",
2684 "session_id": "sess-usr",
2685 "uuid": "user-uuid-1",
2686 "tool_use_result": {"stdout": "ok"},
2687 "message": {
2688 "role": "user",
2689 "content": [
2690 {"type": "tool_result", "tool_use_id": "toolu_01", "content": "ok", "is_error": false}
2691 ]
2692 }
2693 });
2694 let msg: Message = serde_json::from_value(raw).expect("parse");
2695 match msg {
2696 Message::User { uuid, tool_use_result, .. } => {
2697 assert_eq!(uuid.as_deref(), Some("user-uuid-1"));
2698 assert_eq!(tool_use_result, Some(json!({"stdout": "ok"})));
2699 }
2700 other => panic!("expected User, got {other:?}"),
2701 }
2702 }
2703
2704 #[test]
2711 fn user_frame_keeps_the_clis_synthetic_stamp() {
2712 let raw = json!({
2713 "type": "user",
2714 "session_id": "sess-usr",
2715 "uuid": "user-uuid-2",
2716 "isSynthetic": true,
2717 "message": {
2718 "role": "user",
2719 "content": [{
2720 "type": "text",
2721 "text": "Skill /unslop was loaded earlier (see the invoked-skills reminder above); this is a NEW invocation."
2722 }]
2723 }
2724 });
2725
2726 let msg: Message = serde_json::from_value(raw).expect("parse");
2727 let encoded = serde_json::to_value(&msg).expect("encode");
2728
2729 assert_eq!(
2730 encoded.get("isSynthetic"),
2731 Some(&json!(true)),
2732 "the stamp must survive decode and re-encode, or every view reads these words as \
2733 the reader's own: {encoded}",
2734 );
2735
2736 let summary = json!({
2740 "type": "user",
2741 "session_id": "sess-summary",
2742 "uuid": "user-uuid-3",
2743 "isSynthetic": true,
2744 "isReplay": false,
2745 "message": {
2746 "role": "user",
2747 "content": "This session is being continued from a previous conversation that ran out of context."
2748 }
2749 });
2750
2751 let msg: Message = serde_json::from_value(summary).expect("parse");
2752 let encoded = serde_json::to_value(&msg).expect("encode");
2753
2754 assert_eq!(
2755 encoded.get("isSynthetic"),
2756 Some(&json!(true)),
2757 "a summary frame is nobody's typed words either: {encoded}",
2758 );
2759 }
2760
2761 #[test]
2768 fn the_wire_stamps_only_with_its_own_spelling() {
2769 let raw = json!({
2770 "type": "user",
2771 "session_id": "sess-usr",
2772 "turnCompanion": true,
2773 "message": {"role": "user", "content": "companion text"}
2774 });
2775
2776 let decoded: Message = serde_json::from_value(raw).expect("parse");
2777 let encoded = serde_json::to_value(&decoded).expect("encode");
2778
2779 assert_eq!(
2780 encoded.get("isSynthetic"),
2781 None,
2782 "a disk field must not stamp a frame: {encoded}",
2783 );
2784 }
2785
2786 #[test]
2791 fn unmarked_user_frames_carry_no_synthetic_key() {
2792 let raw = json!({
2793 "type": "user",
2794 "session_id": "sess-usr",
2795 "message": {"role": "user", "content": "typed by hand"}
2796 });
2797
2798 let decoded: Message = serde_json::from_value(raw).expect("parse");
2799 let encoded = serde_json::to_value(&decoded).expect("encode");
2800
2801 assert_eq!(
2802 encoded.get("isSynthetic"),
2803 None,
2804 "a frame nobody stamped must not grow the key: {encoded}",
2805 );
2806 }
2807
2808 #[test]
2809 fn unknown_wire_values_decode_to_the_catch_all_variant() {
2810 let foreign = || json!("some_future_value_forge_does_not_know");
2816 assert_eq!(
2817 serde_json::from_value::<AssistantMessageError>(foreign()).expect("decode"),
2818 AssistantMessageError::Unknown
2819 );
2820 assert_eq!(
2821 serde_json::from_value::<StopReason>(foreign()).expect("decode"),
2822 StopReason::Unknown
2823 );
2824 assert_eq!(
2825 serde_json::from_value::<RateLimitStatus>(foreign()).expect("decode"),
2826 RateLimitStatus::Unknown
2827 );
2828 assert_eq!(
2829 serde_json::from_value::<RateLimitType>(foreign()).expect("decode"),
2830 RateLimitType::Unknown
2831 );
2832 assert_eq!(
2833 serde_json::from_value::<TaskNotificationStatus>(foreign()).expect("decode"),
2834 TaskNotificationStatus::Unknown
2835 );
2836 assert_eq!(
2837 serde_json::from_value::<crate::runtime::TerminalReason>(foreign()).expect("decode"),
2838 crate::runtime::TerminalReason::Unknown
2839 );
2840 }
2841
2842 #[test]
2850 fn thinking_tokens_decodes_from_wire_shape() {
2851 let raw = json!({
2855 "type": "system",
2856 "subtype": "thinking_tokens",
2857 "estimated_tokens": 1234,
2858 "estimated_tokens_delta": 56,
2859 "uuid": "tt-uuid",
2860 "session_id": "sess-tt",
2861 });
2862 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
2863 let Message::ThinkingTokens {
2864 estimated_tokens,
2865 estimated_tokens_delta,
2866 uuid,
2867 session_id,
2868 ..
2869 } = msg
2870 else {
2871 panic!("expected ThinkingTokens, got {msg:?}");
2872 };
2873 assert_eq!(estimated_tokens, 1234);
2874 assert_eq!(estimated_tokens_delta, 56);
2875 assert_eq!(uuid, "tt-uuid");
2876 assert_eq!(session_id, "sess-tt");
2877 let encoded = serde_json::to_value(&Message::ThinkingTokens {
2879 estimated_tokens: 1234,
2880 estimated_tokens_delta: 56,
2881 uuid: "tt-uuid".to_owned(),
2882 session_id: "sess-tt".to_owned(),
2883 extras: Extras::new(),
2884 })
2885 .expect("encode");
2886 assert_eq!(encoded, raw);
2887 }
2888
2889 #[test]
2890 fn turn_duration_decodes_from_wire_shape() {
2891 let raw = json!({
2895 "type": "system",
2896 "subtype": "turn_duration",
2897 "durationMs": 31051,
2898 "messageCount": 29,
2899 "parent_tool_use_id": "uuid_25",
2900 "session_id": "sess-td",
2901 "uuid": "td-uuid",
2902 });
2903 let msg: Message = serde_json::from_value(raw).expect("decode");
2904 let Message::TurnDuration {
2905 ms, message_count, parent_tool_use_id, session_id, uuid, ..
2906 } = msg
2907 else {
2908 panic!("expected TurnDuration, got {msg:?}");
2909 };
2910 assert_eq!(ms, 31051);
2911 assert_eq!(message_count, Some(29));
2912 assert_eq!(parent_tool_use_id.as_deref(), Some("uuid_25"));
2913 assert_eq!(session_id, "sess-td");
2914 assert_eq!(uuid, "td-uuid");
2915 }
2916
2917 #[test]
2918 fn turn_duration_roundtrip_emits_wire_camel_case_keys() {
2919 let msg = Message::TurnDuration {
2920 ms: 1500,
2921 message_count: Some(3),
2922 parent_tool_use_id: None,
2923 session_id: "sess-rt".to_owned(),
2924 uuid: "rt-uuid".to_owned(),
2925 extras: Extras::new(),
2926 };
2927 let encoded = serde_json::to_value(&msg).expect("encode");
2928 assert_eq!(encoded.get("durationMs").and_then(serde_json::Value::as_u64), Some(1500));
2930 assert_eq!(encoded.get("messageCount").and_then(serde_json::Value::as_u64), Some(3));
2931 assert!(
2933 encoded.get("parent_tool_use_id").is_none(),
2934 "None-valued parent_tool_use_id must NOT serialize",
2935 );
2936 }
2937
2938 #[test]
2944 fn compact_boundary_decodes_the_three_fields_forge_reads() {
2945 let raw = json!({
2946 "type": "system",
2947 "subtype": "compact_boundary",
2948 "session_id": "sess-cb",
2949 "uuid": "cb-uuid",
2950 "compact_metadata": {
2951 "trigger": "manual",
2952 "pre_tokens": 68031,
2953 "post_tokens": 9149,
2954 "cumulative_dropped_tokens": 58882,
2955 "duration_ms": 48928,
2956 "preserved_segment": {"head_uuid": "h", "anchor_uuid": "a", "tail_uuid": "t"},
2957 "preserved_messages": {"anchor_uuid": "a", "uuids": ["h"], "all_uuids": ["h", "t"]},
2958 },
2959 "logical_parent_uuid": "lp-uuid",
2960 });
2961 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
2962 let Message::CompactBoundary {
2963 trigger,
2964 pre_tokens,
2965 post_tokens,
2966 uuid,
2967 session_id,
2968 metadata_extras,
2969 extras,
2970 } = msg
2971 else {
2972 panic!("compact_boundary must decode typed, not into System; got {msg:?}");
2973 };
2974 assert_eq!(trigger, "manual", "trigger drives the pending-compact clear");
2975 assert_eq!(pre_tokens, 68031, "pre_tokens is the number the usage panel shows");
2976 assert_eq!(post_tokens, 9149, "post_tokens is what the row says was carried after the cut");
2977 assert_eq!(uuid, "cb-uuid");
2978 assert_eq!(session_id, "sess-cb");
2979 assert_eq!(
2980 metadata_extras.get("duration_ms"),
2981 Some(&json!(48928)),
2982 "a metadata sibling crosses too; it is a fact about the cut, not noise",
2983 );
2984 assert_eq!(
2985 extras.get("logical_parent_uuid"),
2986 Some(&json!("lp-uuid")),
2987 "and a frame-level sibling crosses beside it",
2988 );
2989
2990 let encoded = serde_json::to_value(&Message::CompactBoundary {
2994 trigger: "manual".to_owned(),
2995 pre_tokens: 68031,
2996 post_tokens: 9149,
2997 uuid: "cb-uuid".to_owned(),
2998 session_id: "sess-cb".to_owned(),
2999 metadata_extras,
3000 extras,
3001 })
3002 .expect("encode");
3003 assert_eq!(
3004 encoded,
3005 json!({
3006 "type": "system",
3007 "subtype": "compact_boundary",
3008 "session_id": "sess-cb",
3009 "uuid": "cb-uuid",
3010 "compact_metadata": {
3011 "trigger": "manual",
3012 "pre_tokens": 68031,
3013 "post_tokens": 9149,
3014 "cumulative_dropped_tokens": 58882,
3015 "duration_ms": 48928,
3016 "preserved_segment": {"head_uuid": "h", "anchor_uuid": "a", "tail_uuid": "t"},
3017 "preserved_messages": {"anchor_uuid": "a", "uuids": ["h"], "all_uuids": ["h", "t"]},
3018 },
3019 "logical_parent_uuid": "lp-uuid",
3020 }),
3021 "encode re-nests the metadata siblings and keeps the frame's own at the top",
3022 );
3023 }
3024
3025 #[test]
3034 fn a_renamed_pre_tokens_falls_to_the_generic_bucket() {
3035 let msg: Message = serde_json::from_value(json!({
3036 "type": "system",
3037 "subtype": "compact_boundary",
3038 "session_id": "sess-cb",
3039 "uuid": "cb-uuid",
3040 "compact_metadata": {"trigger": "manual", "preTokens": 68031, "post_tokens": 9149},
3041 }))
3042 .expect("decode");
3043 let Message::System { subtype, .. } = msg else {
3044 panic!("drifted metadata must NOT satisfy the typed match; got {msg:?}");
3045 };
3046 assert_eq!(subtype, "compact_boundary", "and it stays identifiable as the same subtype");
3047 }
3048
3049 #[test]
3050 fn background_tasks_changed_encode_round_trips() {
3051 let msg = Message::BackgroundTasksChanged {
3052 tasks: vec![
3053 json!({"task_id": "t1", "task_type": "local_workflow", "description": "d"}),
3054 ],
3055 uuid: "bg-uuid".to_owned(),
3056 session_id: "sess-bg".to_owned(),
3057 extras: Extras::new(),
3058 };
3059 let encoded = serde_json::to_value(&msg).expect("encode");
3060 assert_eq!(encoded["type"], "system");
3061 assert_eq!(encoded["subtype"], "background_tasks_changed");
3062 let decoded: Message = serde_json::from_value(encoded).expect("decode");
3063 assert_eq!(decoded, msg);
3064 }
3065
3066 #[test]
3067 fn commands_changed_encode_round_trips() {
3068 let msg = Message::CommandsChanged {
3069 commands: vec![json!({"name": "audit", "description": "sweep"})],
3070 uuid: "cmd-uuid".to_owned(),
3071 session_id: "sess-cmd".to_owned(),
3072 extras: Extras::new(),
3073 };
3074 let encoded = serde_json::to_value(&msg).expect("encode");
3075 assert_eq!(encoded["type"], "system");
3076 assert_eq!(encoded["subtype"], "commands_changed");
3077 let decoded: Message = serde_json::from_value(encoded).expect("decode");
3078 assert_eq!(decoded, msg);
3079 }
3080
3081 #[test]
3082 fn hook_started_encode_round_trips() {
3083 let msg = Message::HookStarted {
3084 hook_id: "h1".to_owned(),
3085 extras: Extras::new(),
3086 hook_name: "SessionStart:startup".to_owned(),
3087 hook_event: "SessionStart".to_owned(),
3088 uuid: "hs-uuid".to_owned(),
3089 session_id: "sess-hs".to_owned(),
3090 };
3091 let encoded = serde_json::to_value(&msg).expect("encode");
3092 assert_eq!(encoded["type"], "system");
3093 assert_eq!(encoded["subtype"], "hook_started");
3094 let decoded: Message = serde_json::from_value(encoded).expect("decode");
3095 assert_eq!(decoded, msg);
3096 }
3097
3098 #[test]
3099 fn hook_response_encode_round_trips() {
3100 let msg = Message::HookResponse {
3101 hook_id: "h1".to_owned(),
3102 hook_name: "SessionStart:startup".to_owned(),
3103 hook_event: "SessionStart".to_owned(),
3104 outcome: "success".to_owned(),
3105 exit_code: 0,
3106 output: "body".to_owned(),
3107 stdout: "body".to_owned(),
3108 stderr: String::new(),
3109 uuid: "hr-uuid".to_owned(),
3110 session_id: "sess-hr".to_owned(),
3111 extras: Extras::new(),
3112 };
3113 let encoded = serde_json::to_value(&msg).expect("encode");
3114 assert_eq!(encoded["type"], "system");
3115 assert_eq!(encoded["subtype"], "hook_response");
3116 let decoded: Message = serde_json::from_value(encoded).expect("decode");
3117 assert_eq!(decoded, msg);
3118 }
3119
3120 #[test]
3121 fn stop_hook_summary_decodes_from_wire_shape() {
3122 let raw = json!({
3131 "type": "system",
3132 "subtype": "stop_hook_summary",
3133 "hasOutput": true,
3134 "hookCount": 2,
3135 "hookErrors": [],
3136 "hookInfos": [
3137 {"command": "bash ~/.claude/hooks/cmux-notify.sh", "durationMs": 980},
3138 {"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh", "durationMs": 17},
3139 ],
3140 "level": "suggestion",
3141 "parent_tool_use_id": "uuid_2",
3142 "preventedContinuation": false,
3143 "session_id": "session_0",
3144 "stopReason": "",
3145 "toolUseID": "5e586a7f",
3146 "uuid": "uuid_3",
3147 });
3148 let msg: Message = serde_json::from_value(raw).expect("decode");
3149 let Message::StopHookSummary {
3150 actions, hook_infos, parent_tool_use_id, session_id, ..
3151 } = msg
3152 else {
3153 panic!("expected StopHookSummary, got {msg:?}");
3154 };
3155 assert_eq!(actions, 2, "hookCount -> actions");
3156 assert_eq!(hook_infos.len(), 2);
3157 assert_eq!(hook_infos[0].command, "bash ~/.claude/hooks/cmux-notify.sh");
3158 assert_eq!(hook_infos[0].duration_ms, Some(980));
3159 assert_eq!(parent_tool_use_id.as_deref(), Some("uuid_2"));
3160 assert_eq!(session_id, "session_0");
3161 }
3162
3163 #[test]
3164 fn stop_hook_summary_errors_cross_the_crate() {
3165 let raw = json!({
3170 "type": "system",
3171 "subtype": "stop_hook_summary",
3172 "hookCount": 1,
3173 "hookInfos": [{"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh", "durationMs": 0}],
3174 "hookErrors": [
3175 "Failed to run: Plugin directory does not exist: /Users/vedhavyas/.claude/plugins/cache/claude-code-plugins/ralph-wiggum/1.0.0 (ralph-wiggum@claude-code-plugins \u{2014} run /plugin to reinstall)"
3176 ],
3177 "hookAdditionalContext": [],
3178 "preventedContinuation": false,
3179 "stopReason": "",
3180 "hasOutput": true,
3181 "level": "suggestion",
3182 "toolUseID": "8bdfbd8a-a578-441d-97ff-4d8a2923e1e7",
3183 "session_id": "3dc2afa8-fdd1-40ff-bc7d-ffaace19246a",
3184 "uuid": "225cae5c-f638-4b31-afb2-700b8303dc16",
3185 });
3186 let msg: Message = serde_json::from_value(raw).expect("decode");
3187 let Message::StopHookSummary { .. } = &msg else {
3188 panic!("expected the typed variant, got {msg:?}");
3189 };
3190 let encoded = serde_json::to_value(&msg).expect("encode");
3191 assert_eq!(
3192 encoded["hookErrors"],
3193 json!([
3194 "Failed to run: Plugin directory does not exist: /Users/vedhavyas/.claude/plugins/cache/claude-code-plugins/ralph-wiggum/1.0.0 (ralph-wiggum@claude-code-plugins \u{2014} run /plugin to reinstall)"
3195 ]),
3196 "the key a client reads is on the wire, not only on the CLI's bytes"
3197 );
3198
3199 let quiet = json!({
3203 "type": "system",
3204 "subtype": "stop_hook_summary",
3205 "hookCount": 2,
3206 "hookInfos": [{"command": "bash hook.sh", "durationMs": 980}],
3207 "hookErrors": [],
3208 "level": "suggestion",
3209 "preventedContinuation": false,
3210 "stopReason": "",
3211 "hasOutput": true,
3212 "toolUseID": "5e586a7f",
3213 "session_id": "session_0",
3214 "uuid": "uuid_3",
3215 });
3216 let msg: Message = serde_json::from_value(quiet).expect("decode");
3217 let Message::StopHookSummary { .. } = &msg else {
3218 panic!("expected the typed variant, got {msg:?}");
3219 };
3220 let encoded = serde_json::to_value(&msg).expect("encode");
3221 assert_eq!(encoded["hookErrors"], json!([]), "and empty crosses as empty");
3222 }
3223
3224 #[test]
3225 fn stop_hook_summary_entry_without_duration_decodes_typed() {
3226 let raw = json!({
3231 "type": "system",
3232 "subtype": "stop_hook_summary",
3233 "hasOutput": true,
3234 "hookCount": 2,
3235 "hookErrors": [],
3236 "hookInfos": [
3237 {"command": "bash ~/.claude/hooks/cmux-notify.sh", "durationMs": 980},
3238 {"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reminder.sh",
3239 "promptText": "Check verification completeness"},
3240 ],
3241 "level": "suggestion",
3242 "preventedContinuation": false,
3243 "session_id": "session_0",
3244 "stopReason": "",
3245 "toolUseID": "5e586a7f",
3246 "uuid": "uuid_3",
3247 });
3248 let msg: Message = serde_json::from_value(raw).expect("decode");
3249 let Message::StopHookSummary { actions, hook_infos, .. } = msg else {
3250 panic!("expected StopHookSummary, got {msg:?}");
3251 };
3252 assert_eq!(actions, 2);
3253 assert_eq!(hook_infos[0].duration_ms, Some(980));
3254 assert_eq!(hook_infos[1].duration_ms, None, "durationMs may be absent");
3255 assert_eq!(hook_infos[1].command, "${CLAUDE_PLUGIN_ROOT}/hooks/reminder.sh");
3256 }
3257
3258 #[test]
3259 fn workflow_task_progress_decodes_workflow_progress_array() {
3260 let raw = json!({
3265 "type": "system",
3266 "subtype": "task_progress",
3267 "task_id": "woc6i1sab",
3268 "tool_use_id": "toolu_01XapnWmqm6an1tJYxJn72xs",
3269 "description": "Ping: ping",
3270 "usage": {"total_tokens": 54707, "tool_uses": 1, "duration_ms": 4826},
3271 "last_tool_name": "ping",
3272 "summary": "Minimal one-agent workflow: ask for a fixed structured fact",
3273 "workflow_progress": [
3274 {"type": "workflow_phase", "index": 1, "title": "Ping"},
3275 {
3276 "type": "workflow_agent",
3277 "index": 1,
3278 "label": "ping",
3279 "phaseIndex": 1,
3280 "phaseTitle": "Ping",
3281 "state": "done",
3282 "lastToolName": "StructuredOutput",
3283 "lastToolSummary": "pong",
3284 "resultPreview": "{\"answer\":\"pong\",\"confidence\":1}",
3285 "agentId": "abf",
3286 "model": "claude-opus-4-7",
3287 "queuedAt": 1_780_047_148_162_u64,
3288 "startedAt": 1_780_047_148_173_u64,
3289 "attempt": 1,
3290 "promptPreview": "Reply with the single word pong.",
3291 "lastProgressAt": 1_780_047_153_041_u64,
3292 "tokens": 54707,
3293 "toolCalls": 1,
3294 "durationMs": 4868,
3295 },
3296 ],
3297 "uuid": "8f030768-6fda-4ea9-a122-9beb16e8d6e3",
3298 "session_id": "session_z",
3299 });
3300 let msg: Message = serde_json::from_value(raw).expect("decode");
3301 let Message::TaskProgress { workflow_progress, task_id, tool_use_id, .. } = msg else {
3302 panic!("expected TaskProgress");
3303 };
3304 assert_eq!(task_id, "woc6i1sab");
3305 assert_eq!(tool_use_id.as_deref(), Some("toolu_01XapnWmqm6an1tJYxJn72xs"));
3306 assert_eq!(workflow_progress.len(), 2);
3307 let WorkflowProgressEvent::WorkflowPhase { index, title, .. } = &workflow_progress[0]
3308 else {
3309 panic!("first event must be WorkflowPhase, got {:?}", workflow_progress[0]);
3310 };
3311 assert_eq!(*index, 1);
3312 assert_eq!(title, "Ping");
3313 let WorkflowProgressEvent::WorkflowAgent {
3314 phase_index,
3315 phase_title,
3316 state,
3317 last_tool_name,
3318 last_tool_summary,
3319 result_preview,
3320 extras,
3321 ..
3322 } = &workflow_progress[1]
3323 else {
3324 panic!("second event must be WorkflowAgent, got {:?}", workflow_progress[1]);
3325 };
3326 assert_eq!(*phase_index, Some(1));
3327 assert_eq!(phase_title, &Some("Ping".to_string()));
3328 assert_eq!(state, "done");
3329 assert_eq!(last_tool_name.as_deref(), Some("StructuredOutput"));
3330 assert_eq!(last_tool_summary.as_deref(), Some("pong"));
3331 assert_eq!(result_preview.as_deref(), Some("{\"answer\":\"pong\",\"confidence\":1}"));
3332 assert_eq!(
3333 extras.get("agentId"),
3334 Some(&json!("abf")),
3335 "the entry's unmodelled facts (agentId, model, timings, tokens) stay on the entry",
3336 );
3337 assert_eq!(extras.get("tokens"), Some(&json!(54_707)));
3338 assert_eq!(extras.get("model"), Some(&json!("claude-opus-4-7")));
3339
3340 let encoded = serde_json::to_value(&Message::TaskProgress {
3343 task_id: "woc6i1sab".to_owned(),
3344 description: String::new(),
3345 usage: TaskUsage {
3346 total_tokens: 0,
3347 tool_uses: 0,
3348 duration_ms: 0,
3349 extras: Extras::new(),
3350 },
3351 uuid: "u".to_owned(),
3352 session_id: "s".to_owned(),
3353 tool_use_id: None,
3354 last_tool_name: None,
3355 workflow_progress: vec![WorkflowProgressEvent::WorkflowAgent {
3356 index: 2,
3357 label: "pong".to_owned(),
3358 phase_index: Some(1),
3359 phase_title: None,
3360 state: "done".to_owned(),
3361 last_tool_name: None,
3362 last_tool_summary: None,
3363 result_preview: None,
3364 extras: extras.clone(),
3365 }],
3366 extras: Extras::new(),
3367 })
3368 .expect("encode");
3369 assert_eq!(encoded["workflow_progress"][0]["agentId"], "abf");
3370 assert_eq!(encoded.get("agentId"), None, "an entry's extras never leak up to the frame");
3371 }
3372
3373 #[test]
3374 fn workflow_task_progress_without_workflow_progress_decodes_with_empty_default() {
3375 let raw = json!({
3379 "type": "system",
3380 "subtype": "task_progress",
3381 "task_id": "t-sub",
3382 "description": "subagent halfway",
3383 "usage": {"total_tokens": 10, "tool_uses": 1, "duration_ms": 100},
3384 "uuid": "u-sub",
3385 "session_id": "sess",
3386 });
3387 let msg: Message = serde_json::from_value(raw).expect("decode");
3388 let Message::TaskProgress { workflow_progress, .. } = msg else {
3389 panic!("expected TaskProgress");
3390 };
3391 assert!(workflow_progress.is_empty());
3392 }
3393
3394 #[test]
3395 fn workflow_progress_unknown_type_decodes_as_other() {
3396 let raw = json!({"type": "workflow_future", "anything": 42});
3400 let event: WorkflowProgressEvent = serde_json::from_value(raw).expect("decode");
3401 assert!(matches!(event, WorkflowProgressEvent::Other));
3402 }
3403
3404 #[test]
3405 fn stop_hook_summary_with_zero_hooks_decodes_cleanly() {
3406 let raw = json!({
3410 "type": "system",
3411 "subtype": "stop_hook_summary",
3412 "hasOutput": false,
3413 "hookCount": 0,
3414 "hookErrors": [],
3415 "hookInfos": [],
3416 "level": "suggestion",
3417 "preventedContinuation": false,
3418 "session_id": "session_z",
3419 "stopReason": "",
3420 "toolUseID": "tu-z",
3421 "uuid": "uuid_z",
3422 });
3423 let msg: Message = serde_json::from_value(raw).expect("decode");
3424 let Message::StopHookSummary { actions, hook_infos, .. } = msg else {
3425 panic!("expected StopHookSummary");
3426 };
3427 assert_eq!(actions, 0);
3428 assert!(hook_infos.is_empty());
3429 }
3430
3431 #[test]
3432 fn background_tasks_changed_decodes_as_typed_variant() {
3433 let raw = json!({
3434 "type": "system",
3435 "subtype": "background_tasks_changed",
3436 "tasks": [{
3437 "task_id": "wfm8lm8vx",
3438 "task_type": "local_workflow",
3439 "description": "Fan out two trivial agents returning ping and pong",
3440 }],
3441 "uuid": "145d5225-f94f-411a-b76b-d7bef6506eff",
3442 "session_id": "c35950bc-376e-4c74-be2d-8f31f32c613b",
3443 });
3444 let msg: Message = serde_json::from_value(raw).expect("decode");
3445 let Message::BackgroundTasksChanged { tasks, session_id, .. } = msg else {
3446 panic!("expected BackgroundTasksChanged, got {msg:?}");
3447 };
3448 assert_eq!(tasks.len(), 1);
3449 assert_eq!(tasks[0]["task_id"], "wfm8lm8vx");
3450 assert_eq!(session_id, "c35950bc-376e-4c74-be2d-8f31f32c613b");
3451 }
3452
3453 #[test]
3454 fn commands_changed_decodes_as_typed_variant() {
3455 let raw = json!({
3456 "type": "system",
3457 "subtype": "commands_changed",
3458 "commands": [{"name": "audit", "description": "sweep a codebase"}],
3459 "uuid": "72af9cde-8c41-434d-baea-f2353a66dab8",
3460 "session_id": "35f88fef-f4c4-4c35-bffa-e363335aa2ac",
3461 });
3462 let msg: Message = serde_json::from_value(raw).expect("decode");
3463 let Message::CommandsChanged { commands, .. } = msg else {
3464 panic!("expected CommandsChanged, got {msg:?}");
3465 };
3466 assert_eq!(commands.len(), 1);
3467 assert_eq!(commands[0]["name"], "audit");
3468 }
3469
3470 #[test]
3471 fn hook_started_decodes_as_typed_variant() {
3472 let raw = json!({
3473 "type": "system",
3474 "subtype": "hook_started",
3475 "hook_id": "cc3b0e3e-f894-43dd-9819-e594a4aa4904",
3476 "hook_name": "SessionStart:startup",
3477 "hook_event": "SessionStart",
3478 "uuid": "0a58da42-e1bd-4b87-9d50-711800d43388",
3479 "session_id": "c35950bc-376e-4c74-be2d-8f31f32c613b",
3480 });
3481 let msg: Message = serde_json::from_value(raw).expect("decode");
3482 let Message::HookStarted { hook_name, hook_event, hook_id, .. } = msg else {
3483 panic!("expected HookStarted, got {msg:?}");
3484 };
3485 assert_eq!(hook_name, "SessionStart:startup");
3486 assert_eq!(hook_event, "SessionStart");
3487 assert_eq!(hook_id, "cc3b0e3e-f894-43dd-9819-e594a4aa4904");
3488 }
3489
3490 #[test]
3491 fn hook_response_decodes_as_typed_variant() {
3492 let raw = json!({
3493 "type": "system",
3494 "subtype": "hook_response",
3495 "hook_id": "946738fb-ca47-4c50-861f-d763fd50eab9",
3496 "hook_name": "SessionStart:startup",
3497 "hook_event": "SessionStart",
3498 "output": "index body",
3499 "outcome": "success",
3500 "exit_code": 0,
3501 "stderr": "",
3502 "stdout": "index body",
3503 "uuid": "d6867063-339c-469d-803d-169cddc5b4eb",
3504 "session_id": "c35950bc-376e-4c74-be2d-8f31f32c613b",
3505 });
3506 let msg: Message = serde_json::from_value(raw).expect("decode");
3507 let Message::HookResponse { outcome, exit_code, stdout, .. } = msg else {
3508 panic!("expected HookResponse, got {msg:?}");
3509 };
3510 assert_eq!(outcome, "success");
3511 assert_eq!(exit_code, 0);
3512 assert_eq!(stdout, "index body");
3513 }
3514
3515 #[test]
3516 fn hook_progress_decodes_as_typed_variant() {
3517 let raw = json!({
3518 "type": "system",
3519 "subtype": "hook_progress",
3520 "hook_id": "012697b9-e191-42e6-9385-cee11f7a17d3",
3521 "hook_name": "SessionStart:startup",
3522 "hook_event": "SessionStart",
3523 "stdout": "{\"async\": true}",
3524 "stderr": "",
3525 "output": "{\"async\": true}",
3526 "uuid": "28d6a071-ae36-4cb8-bbdb-86686acea753",
3527 "session_id": "e30daa8a-1702-4afd-8379-cab1d235935e",
3528 });
3529 let msg: Message = serde_json::from_value(raw).expect("decode");
3530 let Message::HookProgress { hook_id, hook_name, hook_event, stdout, .. } = msg else {
3531 panic!("expected HookProgress, got {msg:?}");
3532 };
3533 assert_eq!(hook_id, "012697b9-e191-42e6-9385-cee11f7a17d3");
3534 assert_eq!(hook_name, "SessionStart:startup");
3535 assert_eq!(hook_event, "SessionStart");
3536 assert_eq!(stdout, "{\"async\": true}");
3537 }
3538
3539 #[test]
3540 fn permission_denied_decodes_as_typed_variant() {
3541 let raw = json!({
3543 "type": "system",
3544 "subtype": "permission_denied",
3545 "tool_name": "Bash",
3546 "tool_use_id": "toolu_01FCzjj9ZBDd3HA6HF1LaBGE",
3547 "decision_reason_type": "other",
3548 "decision_reason": "Contains simple_expansion",
3549 "message": "Contains simple_expansion",
3550 "uuid": "8c7161ee-b318-485e-b733-92cea085ac5b",
3551 "session_id": "4513c0dc-06a8-4c91-add5-9fdad5d00783",
3552 });
3553 let msg: Message = serde_json::from_value(raw).expect("decode");
3554 let Message::PermissionDenied { tool_name, tool_use_id, decision_reason, .. } = msg else {
3555 panic!("expected PermissionDenied, got {msg:?}");
3556 };
3557 assert_eq!(tool_name, "Bash");
3558 assert_eq!(tool_use_id, "toolu_01FCzjj9ZBDd3HA6HF1LaBGE");
3559 assert_eq!(decision_reason.as_deref(), Some("Contains simple_expansion"));
3560 }
3561
3562 #[test]
3563 fn a_denial_missing_its_optional_fields_still_decodes_typed() {
3564 let raw = json!({
3569 "type": "system",
3570 "subtype": "permission_denied",
3571 "tool_name": "Bash",
3572 "tool_use_id": "call_ea708f6c475448e193a787b0",
3573 "agent_id": "a942e0767f027631a",
3574 "decision_reason_type": "subcommandResults",
3575 "message": "Permission to use Bash with command rm -rf /tmp/x has been denied.",
3576 "uuid": "5b03282c-f2fe-4795-93a8-b56f61822725",
3577 "session_id": "f1492219-e1a5-4c39-8cec-ff692498cbc8",
3578 });
3579 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
3580 let Message::PermissionDenied { decision_reason, extras, .. } = msg else {
3581 panic!("expected PermissionDenied, got {msg:?}");
3582 };
3583 assert_eq!(decision_reason, None, "the token is absent, and absent is not a failure");
3584 assert_eq!(
3585 extras.get("agent_id").and_then(serde_json::Value::as_str),
3586 Some("a942e0767f027631a"),
3587 "and the agent stays reachable through the unmodelled fields",
3588 );
3589 assert_eq!(
3590 serde_json::to_value(serde_json::from_value::<Message>(raw).expect("decode"))
3591 .expect("encode"),
3592 json!({
3593 "type": "system",
3594 "subtype": "permission_denied",
3595 "tool_name": "Bash",
3596 "tool_use_id": "call_ea708f6c475448e193a787b0",
3597 "agent_id": "a942e0767f027631a",
3598 "decision_reason_type": "subcommandResults",
3599 "message": "Permission to use Bash with command rm -rf /tmp/x has been denied.",
3600 "uuid": "5b03282c-f2fe-4795-93a8-b56f61822725",
3601 "session_id": "f1492219-e1a5-4c39-8cec-ff692498cbc8",
3602 }),
3603 "and it round-trips byte for byte: no key the CLI omitted is invented",
3604 );
3605
3606 let bare = json!({
3612 "type": "system",
3613 "subtype": "permission_denied",
3614 "tool_name": "Bash",
3615 "tool_use_id": "call_bare",
3616 "agent_id": "a942e0767f027631a",
3617 "message": "Permission to use Bash has been denied.",
3618 "uuid": "u-bare",
3619 "session_id": "s",
3620 });
3621 let msg: Message = serde_json::from_value(bare.clone()).expect("decode");
3622 assert!(
3623 matches!(
3624 &msg,
3625 Message::PermissionDenied { decision_reason_type: None, extras, .. }
3626 if extras.get("agent_id").and_then(serde_json::Value::as_str)
3627 == Some("a942e0767f027631a")
3628 ),
3629 "the decision class is optional too, and the agent stays reachable: {msg:?}",
3630 );
3631 assert_eq!(
3632 serde_json::to_value(&msg).expect("encode"),
3633 bare,
3634 "and the frame it re-emits is the frame it was given",
3635 );
3636 }
3637
3638 #[test]
3639 fn workflow_task_progress_agent_without_phase_decodes_typed() {
3640 let raw = json!({
3645 "type": "system",
3646 "subtype": "task_progress",
3647 "task_id": "wq8nlqkoi",
3648 "tool_use_id": "call_17ac8f53355f48eba45c1a39",
3649 "description": "ping",
3650 "usage": {"total_tokens": 0, "tool_uses": 0, "duration_ms": 27},
3651 "last_tool_name": "ping",
3652 "summary": "Two trivial agents in parallel",
3653 "workflow_progress": [
3654 {
3655 "type": "workflow_agent",
3656 "index": 1,
3657 "label": "ping",
3658 "agentId": "a05487a5d7a14d1db",
3659 "model": "claude-opus-4-8",
3660 "state": "start",
3661 "startedAt": 1_788_848_411_456_u64,
3662 "queuedAt": 1_788_848_411_455_u64,
3663 "attempt": 1,
3664 "promptPreview": "Reply with the single word ping.",
3665 "lastProgressAt": 1_788_848_411_456_u64,
3666 },
3667 ],
3668 "uuid": "uuid_a",
3669 "session_id": "session_a",
3670 });
3671 let msg: Message = serde_json::from_value(raw).expect("decode");
3672 let Message::TaskProgress { workflow_progress, .. } = msg else {
3673 panic!("expected TaskProgress, got {msg:?}");
3674 };
3675 let WorkflowProgressEvent::WorkflowAgent { phase_index, phase_title, state, .. } =
3676 &workflow_progress[0]
3677 else {
3678 panic!("expected WorkflowAgent, got {:?}", workflow_progress[0]);
3679 };
3680 assert_eq!(*phase_index, None);
3681 assert_eq!(phase_title, &None);
3682 assert_eq!(state, "start");
3683 }
3684
3685 #[test]
3686 fn notification_decodes_as_typed_variant() {
3687 let raw = json!({
3688 "type": "system",
3689 "subtype": "notification",
3690 "key": "stop-hook-error",
3691 "text": "Stop hook error occurred \u{b7} ctrl+o to see",
3692 "priority": "immediate",
3693 "session_id": "e0aed9bb-b0de-43d5-8ded-4c5c1778f0fd",
3694 "uuid": "9d893480-969b-4f02-9ce6-666f87184fa0",
3695 });
3696 let msg: Message = serde_json::from_value(raw).expect("decode");
3697 let Message::Notification { key, text, priority, .. } = msg else {
3698 panic!("expected Notification, got {msg:?}");
3699 };
3700 assert_eq!(key.as_deref(), Some("stop-hook-error"));
3701 assert_eq!(text, "Stop hook error occurred \u{b7} ctrl+o to see");
3702 assert_eq!(priority.as_deref(), Some("immediate"));
3703 }
3704
3705 #[test]
3706 fn status_subtype_stays_generic_system() {
3707 let raw = json!({
3711 "type": "system",
3712 "subtype": "status",
3713 "status": null,
3714 "permissionMode": "plan",
3715 "uuid": "c77b2ac7-825f-4fb6-a7e5-3d6fd940b293",
3716 "session_id": "2b5e2c9f-70c7-4df5-81fa-1b5507ce96a4",
3717 });
3718 let msg: Message = serde_json::from_value(raw).expect("decode");
3719 let Message::System { subtype, data, .. } = msg else {
3720 panic!("expected generic System, got {msg:?}");
3721 };
3722 assert_eq!(subtype, "status");
3723 assert_eq!(data["permissionMode"], "plan");
3724 }
3725
3726 #[test]
3734 fn a_task_started_frame_keeps_the_fields_no_view_reads_yet() {
3735 let raw = json!({
3736 "type": "system",
3737 "subtype": "task_started",
3738 "task_id": "aef3c170a790e8ca7",
3739 "tool_use_id": "call_b7855529e53e4747bd1f5747",
3740 "description": "alpha-work",
3741 "subagent_type": "general-purpose",
3742 "is_backgrounded": false,
3743 "spawn_depth": 1,
3744 "task_type": "local_agent",
3745 "prompt": "First write one short sentence of setup text.",
3746 "uuid": "af3bfd20-5ae3-48f5-b092-be3fb3017e79",
3747 "session_id": "ee9485a0-93c8-46c7-940d-3acaa74c60cf",
3748 });
3749 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
3750 let Message::TaskStarted { task_id, tool_use_id, .. } = &msg else {
3751 panic!("expected TaskStarted, got {msg:?}");
3752 };
3753 assert_eq!(task_id, "aef3c170a790e8ca7", "the modelled fields still decode");
3754 assert_eq!(
3755 tool_use_id.as_deref(),
3756 Some("call_b7855529e53e4747bd1f5747"),
3757 "and the dispatch link stays the link",
3758 );
3759
3760 let encoded = serde_json::to_value(&msg).expect("encode");
3761 assert_eq!(
3762 encoded, raw,
3763 "every field the CLI sent must survive decode and re-serialization",
3764 );
3765 }
3766
3767 #[test]
3773 fn a_subagent_inner_assistant_frame_keeps_its_type_description_and_nested_extra() {
3774 let raw = json!({
3775 "type": "assistant",
3776 "message": {
3777 "id": "msg_011CfLJAJ6Ks5wkwmWpki9J9",
3778 "role": "assistant",
3779 "model": "claude-opus-5",
3780 "provider": "firstParty",
3781 "container": null,
3782 "content": [{
3783 "type": "tool_use",
3784 "id": "toolu_01PRrDjCCJb6F7rS3TQXzWch",
3785 "name": "Bash",
3786 "input": {"command": "echo forge-subagent-ok"},
3787 "caller": {"type": "direct"},
3788 }],
3789 "stop_reason": "tool_use",
3790 "usage": {
3791 "input_tokens": 2,
3792 "output_tokens": 31,
3793 "service_tier": "standard",
3794 "inference_geo": "not_available",
3795 },
3796 },
3797 "parent_tool_use_id": "toolu_017brkbqcpsQAmHTWV6unGpQ",
3798 "session_id": "b84b585a-da00-4eb8-827d-935aacfef568",
3799 "uuid": "0e72be3a-647d-4e4b-bf27-6a91a6db5ee6",
3800 "timestamp": "2026-09-23T11:31:44.125Z",
3801 "request_id": "req_011CfL5GYC4ALgjAdC1ekWhH",
3802 "subagent_type": "general-purpose",
3803 "task_description": "Run echo command",
3804 });
3805 let msg: Message = serde_json::from_value(raw).expect("decode");
3806 let Message::Assistant { parent_tool_use_id, .. } = &msg else {
3807 panic!("expected Assistant, got {msg:?}");
3808 };
3809 assert_eq!(
3810 parent_tool_use_id.as_deref(),
3811 Some("toolu_017brkbqcpsQAmHTWV6unGpQ"),
3812 "the parent link is modelled and stays",
3813 );
3814
3815 let encoded = serde_json::to_value(&msg).expect("encode");
3816 assert_eq!(encoded["subagent_type"], "general-purpose", "the agent type survives");
3817 assert_eq!(encoded["task_description"], "Run echo command", "the dispatch's name survives");
3818 assert_eq!(
3819 encoded["request_id"], "req_011CfL5GYC4ALgjAdC1ekWhH",
3820 "the request id survives"
3821 );
3822 assert_eq!(
3823 encoded["message"]["provider"], "firstParty",
3824 "an unmodelled envelope key survives"
3825 );
3826 assert_eq!(encoded["message"]["container"], Value::Null, "including an explicit null");
3827 assert_eq!(
3828 encoded["message"]["usage"]["service_tier"], "standard",
3829 "and the usage detail survives beside the counters forge counts",
3830 );
3831 assert_eq!(
3832 encoded["message"]["content"][0]["caller"]["type"], "direct",
3833 "and an unmodelled key inside the content block's own object survives",
3834 );
3835 }
3836
3837 #[test]
3841 fn a_subagent_inner_user_frame_keeps_its_type_and_description() {
3842 let raw = json!({
3843 "type": "user",
3844 "message": {
3845 "role": "user",
3846 "content": [{
3847 "tool_use_id": "toolu_01PRrDjCCJb6F7rS3TQXzWch",
3848 "type": "tool_result",
3849 "content": "forge-subagent-ok",
3850 "is_error": false,
3851 }],
3852 },
3853 "parent_tool_use_id": "toolu_017brkbqcpsQAmHTWV6unGpQ",
3854 "session_id": "b84b585a-da00-4eb8-827d-935aacfef568",
3855 "uuid": "83f5bfa9-1510-4bbe-a0db-d3a0ceb54f12",
3856 "timestamp": "2026-09-23T11:31:46.071Z",
3857 "subagent_type": "general-purpose",
3858 "task_description": "Run echo command",
3859 });
3860 let msg: Message = serde_json::from_value(raw).expect("decode");
3861 let Message::User { parent_tool_use_id, .. } = &msg else {
3862 panic!("expected User, got {msg:?}");
3863 };
3864 assert_eq!(parent_tool_use_id.as_deref(), Some("toolu_017brkbqcpsQAmHTWV6unGpQ"));
3865
3866 let encoded = serde_json::to_value(&msg).expect("encode");
3867 assert_eq!(encoded["subagent_type"], "general-purpose", "the agent type survives");
3868 assert_eq!(encoded["task_description"], "Run echo command", "the dispatch's name survives");
3869 assert_eq!(
3870 encoded["message"]["content"][0]["content"], "forge-subagent-ok",
3871 "and the inner result stays whole",
3872 );
3873 }
3874
3875 #[test]
3879 fn a_result_frame_keeps_the_subagent_rollup_and_latency_fields() {
3880 let raw = json!({
3881 "type": "result",
3882 "subtype": "success",
3883 "is_error": false,
3884 "num_turns": 3,
3885 "duration_ms": 12000,
3886 "duration_api_ms": 9000,
3887 "result": "done",
3888 "session_id": "ee9485a0-93c8-46c7-940d-3acaa74c60cf",
3889 "subagent_stats": {
3890 "spawned": 2,
3891 "requested": {"background": 1, "foreground": 1, "unset": 0},
3892 "started_in_background": 1,
3893 "max_depth": 1,
3894 "spawned_by_subagents": 0,
3895 "completed": 2,
3896 "failed": 0,
3897 "killed": {"parent": 0, "user": 0, "system": 0},
3898 "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0},
3899 "by_type": {"general-purpose": 2},
3900 },
3901 "ttft_ms": 1200,
3902 "queued_turn_count": 0,
3903 });
3904 let msg: Message = serde_json::from_value(raw).expect("decode");
3905 let Message::Result { num_turns, .. } = &msg else {
3906 panic!("expected Result, got {msg:?}");
3907 };
3908 assert_eq!(*num_turns, 3, "the modelled fields still decode");
3909
3910 let encoded = serde_json::to_value(&msg).expect("encode");
3911 assert_eq!(encoded["subagent_stats"]["spawned"], 2, "the roll-up survives");
3912 assert_eq!(
3913 encoded["subagent_stats"]["by_type"]["general-purpose"], 2,
3914 "including the per-type tally",
3915 );
3916 assert_eq!(encoded["ttft_ms"], 1200, "and the latency field survives");
3917 }
3918
3919 #[test]
3923 fn every_frame_category_keeps_an_unmodelled_key() {
3924 let cases = [
3925 (
3926 json!({
3927 "type": "rate_limit_event",
3928 "rate_limit_info": {"status": "allowed", "utilization": 0.25},
3929 "uuid": "rl-1",
3930 "session_id": "s",
3931 "window_started_at": 1_790_000_000,
3932 }),
3933 "window_started_at",
3934 ),
3935 (
3936 json!({
3937 "type": "command_lifecycle",
3938 "command_uuid": "c-1",
3939 "state": "queued",
3940 "uuid": "cl-1",
3941 "session_id": "s",
3942 "queue_position": 2,
3943 }),
3944 "queue_position",
3945 ),
3946 (
3947 json!({
3948 "type": "stream_event",
3949 "uuid": "se-1",
3950 "session_id": "s",
3951 "event": {"type": "message_start"},
3952 "parent_tool_use_id": "toolu_dispatch",
3953 "ttl_ms": 30_000,
3954 }),
3955 "ttl_ms",
3956 ),
3957 (json!({"type": "error", "error": "pipe broken", "recoverable": false}), "recoverable"),
3958 ];
3959 for (raw, extra) in cases {
3960 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
3961 let encoded = serde_json::to_value(&msg).expect("encode");
3962 assert_eq!(
3963 encoded.get(extra),
3964 raw.get(extra),
3965 "{extra} must survive decode and re-serialization for {}",
3966 raw["type"],
3967 );
3968 }
3969 }
3970
3971 #[test]
3975 fn a_task_usage_and_patch_keep_their_unmodelled_keys() {
3976 let raw = json!({
3977 "type": "system",
3978 "subtype": "task_progress",
3979 "task_id": "t-1",
3980 "description": "d",
3981 "usage": {"total_tokens": 1, "tool_uses": 2, "duration_ms": 3, "paused_ms": 4},
3982 "uuid": "u-1",
3983 "session_id": "s",
3984 });
3985 let msg: Message = serde_json::from_value(raw).expect("decode");
3986 let encoded = serde_json::to_value(&msg).expect("encode");
3987 assert_eq!(
3988 encoded["usage"]["paused_ms"], 4,
3989 "an unmodelled usage counter crosses under its own object",
3990 );
3991
3992 let raw = json!({
3993 "type": "system",
3994 "subtype": "task_updated",
3995 "task_id": "t-1",
3996 "patch": {"status": "completed", "end_time": 1, "paused_ms": 4},
3997 "uuid": "u-2",
3998 "session_id": "s",
3999 });
4000 let msg: Message = serde_json::from_value(raw).expect("decode");
4001 let encoded = serde_json::to_value(&msg).expect("encode");
4002 assert_eq!(
4003 encoded["patch"]["paused_ms"], 4,
4004 "and an unmodelled patch key crosses the same way",
4005 );
4006 }
4007
4008 #[test]
4012 fn a_task_progress_frame_keeps_the_subagent_type() {
4013 let raw = json!({
4014 "type": "system",
4015 "subtype": "task_progress",
4016 "task_id": "aef3c170a790e8ca7",
4017 "tool_use_id": "call_b7855529e53e4747bd1f5747",
4018 "description": "Running Echo alpha-one",
4019 "subagent_type": "general-purpose",
4020 "usage": {"total_tokens": 10314, "tool_uses": 1, "duration_ms": 1669},
4021 "last_tool_name": "Bash",
4022 "uuid": "c8128321-b588-45d3-9b69-23cb638345ce",
4023 "session_id": "ee9485a0-93c8-46c7-940d-3acaa74c60cf",
4024 });
4025 let msg: Message = serde_json::from_value(raw).expect("decode");
4026 let Message::TaskProgress { usage, last_tool_name, .. } = &msg else {
4027 panic!("expected TaskProgress, got {msg:?}");
4028 };
4029 assert_eq!(usage.tool_uses, 1, "the modelled usage still decodes");
4030 assert_eq!(last_tool_name.as_deref(), Some("Bash"));
4031
4032 let encoded = serde_json::to_value(&msg).expect("encode");
4033 assert_eq!(encoded["subagent_type"], "general-purpose", "the agent type survives");
4034 }
4035}
4036
4037#[cfg(test)]
4038mod tests_stamp {
4039 use serde_json::json;
4040
4041 use super::Message;
4042
4043 fn kinds() -> Vec<(&'static str, serde_json::Value, &'static str)> {
4055 vec![
4056 (
4057 "user",
4058 json!({
4059 "type": "user",
4060 "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]},
4061 "session_id": "s1",
4062 }),
4063 AT,
4064 ),
4065 (
4066 "assistant",
4067 json!({
4068 "type": "assistant",
4069 "uuid": "a1",
4070 "timestamp": "2026-10-15T05:18:15.544Z",
4071 "session_id": "s1",
4072 "message": {
4073 "id": "m1",
4074 "role": "assistant",
4075 "model": "claude-opus-5",
4076 "content": [{"type": "text", "text": "working"}],
4077 },
4078 }),
4079 "2026-10-15T05:18:15.544Z",
4080 ),
4081 (
4082 "system",
4083 json!({
4084 "type": "system",
4085 "subtype": "hook_drift_probe",
4086 "session_id": "s1",
4087 "detail": "an unmodelled subtype",
4088 }),
4089 AT,
4090 ),
4091 (
4092 "system the sender stamped",
4093 json!({
4094 "type": "system",
4095 "subtype": "hook_drift_probe",
4096 "session_id": "s1",
4097 "detail": "an unmodelled subtype",
4098 "timestamp": "2026-10-15T05:19:00.100Z",
4099 }),
4100 "2026-10-15T05:19:00.100Z",
4101 ),
4102 (
4103 "thinking_tokens",
4104 json!({
4105 "type": "system",
4106 "subtype": "thinking_tokens",
4107 "estimated_tokens": 40,
4108 "estimated_tokens_delta": 40,
4109 "uuid": "t1",
4110 "session_id": "s1",
4111 "user_message_uuid": "u1",
4112 }),
4113 AT,
4114 ),
4115 (
4116 "stop_hook_summary",
4117 json!({
4118 "type": "system",
4119 "subtype": "stop_hook_summary",
4120 "hookCount": 1,
4121 "hookInfos": [],
4122 "hookErrors": [],
4123 "hasOutput": false,
4124 "level": "suggestion",
4125 "preventedContinuation": false,
4126 "stopReason": "",
4127 "toolUseID": "tu1",
4128 "uuid": "h1",
4129 "session_id": "s1",
4130 }),
4131 AT,
4132 ),
4133 (
4134 "compact_boundary",
4135 json!({
4136 "type": "system",
4137 "subtype": "compact_boundary",
4138 "uuid": "b1",
4139 "session_id": "s1",
4140 "compact_metadata": {"trigger": "auto", "pre_tokens": 10, "post_tokens": 2},
4141 }),
4142 AT,
4143 ),
4144 (
4145 "result",
4146 json!({
4147 "type": "result",
4148 "subtype": "success",
4149 "session_id": "s1",
4150 "is_error": false,
4151 "num_turns": 1,
4152 "duration_ms": 12000,
4153 "duration_api_ms": 9000,
4154 }),
4155 AT,
4156 ),
4157 (
4158 "result the sender stamped",
4159 json!({
4160 "type": "result",
4161 "subtype": "success",
4162 "session_id": "s1",
4163 "is_error": false,
4164 "num_turns": 1,
4165 "duration_ms": 12000,
4166 "duration_api_ms": 9000,
4167 "timestamp": "2026-10-15T05:19:40.632Z",
4168 }),
4169 "2026-10-15T05:19:40.632Z",
4170 ),
4171 ]
4172 }
4173
4174 const AT: &str = "2026-10-15T05:18:05.217Z";
4175
4176 #[test]
4184 fn every_kind_carries_a_stamp_at_the_top_level_it_is_read_from() {
4185 for (kind, raw, want) in kinds() {
4186 let mut msg: Message = serde_json::from_value(raw).expect("decode");
4187 msg.stamp_if_absent(AT);
4188
4189 let encoded = serde_json::to_value(&msg).expect("encode");
4190 assert_eq!(
4191 encoded["timestamp"].as_str(),
4192 Some(want),
4193 "{kind} must carry its stamp where a reader finds it",
4194 );
4195 }
4196 }
4197
4198 #[test]
4201 fn a_second_stamp_never_overwrites_the_first() {
4202 let mut msg: Message = serde_json::from_value(json!({
4203 "type": "user",
4204 "message": {"role": "user", "content": [{"type": "text", "text": "hi"}]},
4205 "session_id": "s1",
4206 }))
4207 .expect("decode");
4208
4209 msg.stamp_if_absent(AT);
4210 msg.stamp_if_absent("2026-10-15T06:00:00.000Z");
4211
4212 let encoded = serde_json::to_value(&msg).expect("encode");
4213 assert_eq!(
4214 encoded["timestamp"].as_str(),
4215 Some(AT),
4216 "the first stamp is the row's own; a later one does not walk it forward",
4217 );
4218 }
4219
4220 #[test]
4225 fn vcs_state_changed_decodes_every_captured_kind() {
4226 let commit = json!({
4227 "type": "system",
4228 "subtype": "vcs_state_changed",
4229 "kind": "commit",
4230 "branch": "main",
4231 "cwd": "/tmp/repo",
4232 "uuid": "u1",
4233 "session_id": "s1",
4234 });
4235 let msg: Message = serde_json::from_value(commit.clone()).expect("decode");
4236 let Message::VcsStateChanged { kind, branch, cwd, uuid, session_id, .. } = msg else {
4237 panic!("expected VcsStateChanged, got {msg:?}");
4238 };
4239 assert_eq!(kind, "commit");
4240 assert_eq!(branch.as_deref(), Some("main"));
4241 assert_eq!(cwd, "/tmp/repo");
4242 assert_eq!(uuid, "u1");
4243 assert_eq!(session_id, "s1");
4244
4245 let merged = json!({
4246 "type": "system",
4247 "subtype": "vcs_state_changed",
4248 "kind": "merge",
4249 "cwd": "/tmp/repo",
4250 "uuid": "u2",
4251 "session_id": "s1",
4252 });
4253 let msg: Message = serde_json::from_value(merged).expect("decode");
4254 let Message::VcsStateChanged { kind, branch, .. } = msg else {
4255 panic!("a branchless mutation still reaches the typed variant");
4256 };
4257 assert_eq!(kind, "merge");
4258 assert_eq!(branch, None);
4259 }
4260
4261 #[test]
4263 fn code_change_published_decodes_the_captured_frame() {
4264 let raw = json!({
4265 "type": "system",
4266 "subtype": "code_change_published",
4267 "provider": "github",
4268 "url": "https://github.com/example/example/pull/123",
4269 "repo": "example/example",
4270 "identifier": "123",
4271 "action": "created",
4272 "branch": "main",
4273 "uuid": "u1",
4274 "session_id": "s1",
4275 });
4276 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
4277 let Message::CodeChangePublished {
4278 provider, url, repo, identifier, action, branch, ..
4279 } = &msg
4280 else {
4281 panic!("expected CodeChangePublished, got {msg:?}");
4282 };
4283 assert_eq!(provider, "github");
4284 assert_eq!(url, "https://github.com/example/example/pull/123");
4285 assert_eq!(repo, "example/example");
4286 assert_eq!(identifier, "123");
4287 assert_eq!(action.as_deref(), Some("created"));
4288 assert_eq!(branch.as_deref(), Some("main"));
4289
4290 let encoded = serde_json::to_value(&msg).expect("encode");
4293 assert_eq!(encoded, raw);
4294 }
4295
4296 #[test]
4300 fn repository_frames_keep_values_this_build_does_not_know() {
4301 let raw = json!({
4302 "type": "system",
4303 "subtype": "vcs_state_changed",
4304 "kind": "worktree-moved",
4305 "branch": "topic",
4306 "cwd": "/tmp/repo",
4307 "uuid": "u1",
4308 "session_id": "s1",
4309 });
4310 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
4311 let Message::VcsStateChanged { kind, .. } = &msg else {
4312 panic!("expected VcsStateChanged, got {msg:?}");
4313 };
4314 assert_eq!(kind, "worktree-moved", "an unknown kind is carried, not refused");
4315 assert_eq!(serde_json::to_value(&msg).expect("encode"), raw);
4316
4317 let raw = json!({
4318 "type": "system",
4319 "subtype": "code_change_published",
4320 "provider": "some-new-forge",
4321 "url": "https://example.test/change/9",
4322 "repo": "a/b",
4323 "identifier": "9",
4324 "action": "auto-merge-enabled",
4325 "uuid": "u2",
4326 "session_id": "s1",
4327 });
4328 let msg: Message = serde_json::from_value(raw.clone()).expect("decode");
4329 let Message::CodeChangePublished { provider, action, .. } = &msg else {
4330 panic!("expected CodeChangePublished, got {msg:?}");
4331 };
4332 assert_eq!(provider, "some-new-forge");
4333 assert_eq!(action.as_deref(), Some("auto-merge-enabled"));
4334 assert_eq!(serde_json::to_value(&msg).expect("encode"), raw);
4335 }
4336}