1use serde::{Deserialize, Serialize};
4
5use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
6use crate::types::StreamEvent;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum Request {
15 Chat {
17 session_id: String,
18 text: String,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
22 attachments: Vec<ChatAttachment>,
23 },
24 CreateSession {
26 #[serde(skip_serializing_if = "Option::is_none")]
27 model: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 provider: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 system_prompt: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 cwd: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 parent_id: Option<String>,
38 #[serde(default)]
40 child_budget: u32,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 tagline: Option<String>,
44 #[serde(default)]
46 auto_archive: bool,
47 #[serde(default = "default_true")]
49 notify_parent: bool,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 project_name: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 sandbox_profile: Option<String>,
56 },
57 GetSessionInfo { session_id: String },
59 GetSessionAncestors { session_id: String },
68 ListSessions {
70 #[serde(default)]
72 include_archived: bool,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 project_name: Option<String>,
76 },
77 ArchiveSession {
79 session_id: String,
80 #[serde(default)]
84 require_ancestor: Option<String>,
85 },
86 RestoreSession { session_id: String },
88 DeleteSession { session_id: String },
90 ListModels,
92 ListAliases {
101 #[serde(default)]
102 cwd: Option<String>,
103 },
104 SetModel {
106 session_id: String,
107 model_id: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
112 caller_session_id: Option<String>,
113 },
114 SetCwd {
116 session_id: String,
117 cwd: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 caller_session_id: Option<String>,
121 },
122 ReparentChildren {
124 old_parent_id: String,
125 new_parent_id: String,
126 },
127 Login { provider: String },
129 AuthStatus,
131 GetSubscriptionUsage,
133 GetMessages { session_id: String },
135 Subscribe { session_id: String },
138 WaitSessions {
140 session_ids: Vec<String>,
141 #[serde(default = "default_wait_timeout")]
142 timeout_secs: u64,
143 },
144 WaitAnySessions {
146 session_ids: Vec<String>,
147 #[serde(default = "default_wait_timeout")]
148 timeout_secs: u64,
149 },
150 CancelChat {
152 session_id: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
157 caller_session_id: Option<String>,
158 },
159 Steer { session_id: String, text: String },
163 Compact {
167 session_id: String,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 keep_hint: Option<String>,
170 },
171
172 QueueMessage {
176 target_session_id: String,
177 content: String,
178 sender_info: String,
179 #[serde(default)]
181 await_reply: bool,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 reply_to: Option<String>,
185 },
186 QueueInfo {
193 target_session_id: String,
194 text: String,
195 },
196 ReplyToMessage { msg_id: String, content: String },
198 ReloadPlugins { session_id: String },
200 ReloadConfig,
210 GcSessions {
212 older_than_days: u64,
214 },
215 FireHook {
217 name: String,
218 data: serde_json::Value,
219 },
220 ExecuteTool {
222 session_id: String,
223 tool_name: String,
224 arguments: serde_json::Value,
225 },
226 EnqueuePostIdleAction {
233 session_id: String,
234 action: crate::types::PostIdleAction,
235 },
236 SetTagline { session_id: String, tagline: String },
238 TaskList {
240 project: String,
241 #[serde(skip_serializing_if = "Option::is_none")]
242 state: Option<String>,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 parent_id: Option<i64>,
245 },
246 TaskGet { id: i64 },
248 TaskCreate {
250 project: String,
251 title: String,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 parent_id: Option<i64>,
254 #[serde(skip_serializing_if = "Option::is_none")]
255 priority: Option<i32>,
256 #[serde(default)]
257 tags: Vec<String>,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 sandbox_profile: Option<String>,
260 },
261 TaskUpdate {
263 id: i64,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 state: Option<String>,
266 #[serde(skip_serializing_if = "Option::is_none")]
267 title: Option<String>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 priority: Option<i64>,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 tags: Option<serde_json::Value>,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 affected_files: Option<serde_json::Value>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 skip_review: Option<bool>,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 require_approval: Option<bool>,
278 #[serde(skip_serializing_if = "Option::is_none")]
279 sandbox_profile: Option<String>,
280 },
281 TaskSearch {
283 project: String,
284 query: String,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 state: Option<String>,
287 },
288 TaskAssign { id: i64, session_id: String },
290 TaskStatus { project: String },
292 TaskOverview {
303 project: String,
304 #[serde(default = "default_recent_limit")]
307 recent_limit: usize,
308 },
309 TaskMergeQueue { project: String },
311 ProjectStats { project_name: String },
316 GetProjectInfo { project_name: String },
321 Shutdown {
323 #[serde(default)]
325 restart: bool,
326 },
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(tag = "type", rename_all = "snake_case")]
335pub enum ChatAttachment {
336 Image { data: String, mime_type: String },
342}
343
344impl ChatAttachment {
345 pub fn to_user_content(&self) -> crate::types::UserContent {
350 match self {
351 ChatAttachment::Image { data, mime_type } => {
352 crate::types::UserContent::Image(crate::types::ImageContent {
353 data: data.clone(),
354 mime_type: mime_type.clone(),
355 })
356 }
357 }
358 }
359}
360
361#[derive(Debug, Serialize, Deserialize, Clone)]
366#[serde(tag = "type", rename_all = "snake_case")]
367pub enum Response {
368 SessionCreated { session_id: String },
370 SessionInfo { info: SessionInfo },
372 SessionAncestors { sessions: Vec<SessionInfo> },
374 Sessions { sessions: Vec<SessionInfo> },
376 SessionDeleted,
378 SessionArchived,
380 SessionRestored,
382 Models { models: Vec<ModelInfo> },
384 Aliases {
389 #[serde(default)]
390 global: Vec<AliasInfo>,
391 #[serde(default)]
392 project: Vec<AliasInfo>,
393 },
394 ModelChanged { model: ModelInfo },
396 Stream { event: Box<StreamEvent> },
398 LoginSuccess { provider: String },
400 AuthStatus { providers: Vec<String> },
402 SubscriptionUsage { usage: SubscriptionUsage },
404 ServerShutdown { restart: bool },
406 SessionsCompleted { results: Vec<SessionResult> },
408 Cancelled,
410 Messages {
412 messages: Vec<crate::types::Message>,
413 },
414 UserMessage { text: String },
416 AgentDone,
418 MessageReply { content: String },
420 Ok,
422 OkWithNote { note: String },
434 GcComplete { deleted: usize },
436 ToolExecuted { content: String, is_error: bool },
438 TaskList { tasks: Vec<TaskInfo> },
440 TaskDetail {
442 task: TaskInfo,
443 messages: Vec<TaskMessageInfo>,
444 relations: Vec<TaskRelationInfo>,
445 subtasks: Vec<TaskInfo>,
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
451 sessions: Vec<TaskSessionInfo>,
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 history: Vec<TaskHistoryInfo>,
455 },
456 TaskUpdated { task: TaskInfo },
458 TaskStatus { text: String },
460 TaskOverview {
462 active: Vec<TaskInfo>,
464 queued_ready: Vec<TaskInfo>,
466 queued_planning: Vec<TaskInfo>,
468 blocked: Vec<TaskInfo>,
470 held: Vec<TaskInfo>,
472 recently_merged: Vec<TaskInfo>,
475 recently_closed: Vec<TaskInfo>,
478 inflight_count: usize,
480 max_concurrent: usize,
482 #[serde(default, skip_serializing_if = "Vec::is_empty")]
487 wait_reasons: Vec<TaskWaitReasons>,
488 },
489 TaskTree { tasks: Vec<(usize, TaskInfo)> },
491 TaskMergeQueue { tasks: Vec<TaskInfo> },
493 ProjectStats { stats: ProjectStatsInfo },
495 ProjectInfo { project: Option<ProjectInfoEntry> },
501 Error { message: String },
503}
504
505pub const SHUTTING_DOWN_ERROR: &str = "__tau_server_shutting_down__";
514
515pub fn is_shutting_down_error(err: &str) -> bool {
520 err == SHUTTING_DOWN_ERROR || err.contains("server is shutting down")
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct SessionInfo {
525 pub id: String,
526 pub model: String,
527 pub provider: String,
528 pub cwd: Option<String>,
529 pub message_count: usize,
530 pub stats: SessionStats,
531 pub last_activity: i64,
533 #[serde(skip_serializing_if = "Option::is_none")]
535 pub parent_id: Option<String>,
536 #[serde(default)]
538 pub child_count: usize,
539 #[serde(default)]
541 pub child_budget: u32,
542 #[serde(skip_serializing_if = "Option::is_none")]
544 pub tagline: Option<String>,
545 #[serde(default = "default_state")]
547 pub state: String,
548 #[serde(skip_serializing_if = "Option::is_none")]
550 pub context_pct: Option<f64>,
551 #[serde(default)]
553 pub archived: bool,
554 #[serde(skip_serializing_if = "Option::is_none")]
556 pub project_name: Option<String>,
557 #[serde(skip_serializing_if = "Option::is_none")]
559 pub last_exit_status: Option<String>,
560 #[serde(default)]
564 pub is_live: bool,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub turn_started_at_ms: Option<u64>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub phase_started_at_ms: Option<u64>,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct SessionResult {
583 pub session_id: String,
584 pub status: String,
586 pub summary: String,
588}
589
590fn default_wait_timeout() -> u64 {
591 300
592}
593
594fn default_true() -> bool {
595 true
596}
597
598fn default_state() -> String {
599 "idle".into()
600}
601
602fn default_recent_limit() -> usize {
603 10
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct ModelInfo {
608 pub id: String,
609 pub name: String,
610 pub provider: String,
611 pub thinking: crate::types::ThinkingStyle,
612 pub context_window: u64,
613 pub max_tokens: u64,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct AliasInfo {
622 pub name: String,
624 pub target: String,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
631pub struct TaskInfo {
632 pub id: i64,
633 pub project_name: String,
634 pub title: String,
635 pub state: String,
636 pub priority: i64,
637 pub parent_id: Option<i64>,
638 pub tags: Option<serde_json::Value>,
639 pub affected_files: Option<serde_json::Value>,
640 pub branch: Option<String>,
641 pub worktree_path: Option<String>,
642 pub session_id: Option<String>,
643 pub skip_review: bool,
644 pub require_approval: bool,
645 #[serde(skip_serializing_if = "Option::is_none")]
646 pub sandbox_profile: Option<String>,
647 #[serde(default)]
648 pub held: bool,
649 #[serde(default)]
654 pub has_live_session: bool,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub filed_by_project: Option<String>,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub filed_by_session_id: Option<String>,
668 pub created_at: i64,
669 pub updated_at: i64,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct TaskMessageInfo {
675 pub id: i64,
676 pub task_id: i64,
677 pub content: String,
678 pub author: Option<String>,
679 pub created_at: i64,
680 pub updated_at: i64,
681}
682
683#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct TaskRelationInfo {
686 pub from_task: i64,
687 pub to_task: i64,
688 pub relation: String,
689}
690
691#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct TaskSessionInfo {
699 pub session_id: String,
701 pub role: String,
703 pub created_at: i64,
705
706 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub message_count: Option<usize>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub archived: Option<bool>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub last_activity: Option<i64>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub last_phase: Option<String>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub last_exit_status: Option<String>,
721 #[serde(default)]
723 pub is_live: bool,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct TaskWaitReasons {
733 pub task_id: i64,
734 pub reasons: Vec<TaskWaitReason>,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
740pub enum TaskWaitReason {
741 Dependency {
743 task_id: i64,
744 title: String,
745 state: String,
746 project_name: String,
747 },
748 FileConflict {
750 files: Vec<String>,
751 with_task_id: i64,
752 },
753 BudgetExhausted { used: usize, max: usize },
755 MergeTargetNotFound { branch: String },
757 NotScheduled,
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct TaskHistoryInfo {
765 pub field: String,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub old_value: Option<String>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub new_value: Option<String>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub session_id: Option<String>,
775 pub created_at: i64,
777}
778
779#[derive(Debug, Clone, Default, Serialize, Deserialize)]
781pub struct SessionStats {
782 pub user_messages: usize,
783 pub assistant_messages: usize,
784 pub tool_calls: usize,
785 pub tool_results: usize,
786 pub tokens: TokenStats,
787 pub cost: f64,
788 pub is_subscription: bool,
790 pub context_window: u64,
792 pub context_tokens: Option<u64>,
794}
795
796#[derive(Debug, Clone, Default, Serialize, Deserialize)]
797pub struct TokenStats {
798 pub input: u64,
799 pub output: u64,
800 pub cache_read: u64,
801 pub cache_write: u64,
802}
803
804impl TokenStats {
805 pub fn total(&self) -> u64 {
806 self.input + self.output + self.cache_read + self.cache_write
807 }
808}
809
810#[derive(Debug, Clone, Default, Serialize, Deserialize)]
815pub struct ProjectStatsInfo {
816 pub project_name: String,
817 pub session_count: usize,
818 pub message_count: usize,
819 pub tokens_input: u64,
820 pub tokens_output: u64,
821 pub tokens_cache_read: u64,
822 pub tokens_cache_write: u64,
823 pub cost_usd: f64,
824 #[serde(skip_serializing_if = "Option::is_none")]
827 pub last_activity: Option<i64>,
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
836pub struct ProjectInfoEntry {
837 pub name: String,
838 pub path: String,
839}
840
841pub fn format_tokens(n: u64) -> String {
843 if n >= 1_000_000 {
844 format!("{:.1}M", n as f64 / 1_000_000.0)
845 } else if n >= 1_000 {
846 format!("{:.1}K", n as f64 / 1_000.0)
847 } else {
848 n.to_string()
849 }
850}
851
852#[allow(clippy::cast_precision_loss)]
855pub fn format_stats(stats: &SessionStats) -> String {
856 let mut parts = Vec::new();
857
858 if stats.tokens.input > 0 {
859 parts.push(format!("↑{}", format_tokens(stats.tokens.input)));
860 }
861 if stats.tokens.output > 0 {
862 parts.push(format!("↓{}", format_tokens(stats.tokens.output)));
863 }
864 if stats.tokens.cache_read > 0 {
865 parts.push(format!("R{}", format_tokens(stats.tokens.cache_read)));
866 }
867 if stats.tokens.cache_write > 0 {
868 parts.push(format!("W{}", format_tokens(stats.tokens.cache_write)));
869 }
870
871 let cost_str = if stats.is_subscription {
872 format!("${:.3} (sub)", stats.cost)
873 } else if stats.cost > 0.0 {
874 format!("${:.3}", stats.cost)
875 } else {
876 String::new()
877 };
878 if !cost_str.is_empty() {
879 parts.push(cost_str);
880 }
881
882 if stats.context_window > 0 {
883 let ctx = match stats.context_tokens {
884 Some(t) => {
885 let pct = (t as f64 / stats.context_window as f64) * 100.0;
886 format!("{:.1}%/{}", pct, format_tokens(stats.context_window))
887 }
888 None => format!("?/{}", format_tokens(stats.context_window)),
889 };
890 parts.push(ctx);
891 }
892
893 parts.join(" ")
894}
895
896fn format_resets_at(resets_at: &str) -> String {
899 let trimmed = resets_at.trim().trim_end_matches('Z');
902 let (date_part, time_part) = match trimmed.split_once('T') {
903 Some(pair) => pair,
904 None => return "?".into(),
905 };
906 let mut date_iter = date_part.split('-');
907 let year: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
908 let month: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
909 let day: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
910
911 let time_clean = time_part
913 .split('+')
914 .next()
915 .unwrap_or(time_part)
916 .split('.')
917 .next()
918 .unwrap_or(time_part);
919 let mut time_iter = time_clean.split(':');
920 let hour: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
921 let minute: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
922 let second: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
923
924 fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
926 let y = if m <= 2 { y - 1 } else { y };
927 let era = y.div_euclid(400);
928 let yoe = y.rem_euclid(400);
929 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
930 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
931 era * 146097 + doe - 719468
932 }
933 let reset_epoch =
934 days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second;
935
936 let now = std::time::SystemTime::now()
937 .duration_since(std::time::UNIX_EPOCH)
938 .unwrap_or_default()
939 .as_secs() as i64;
940
941 let delta = reset_epoch - now;
942 if delta <= 0 {
943 return "?".into();
944 }
945 format_duration_compact(delta)
946}
947
948fn format_duration_compact(secs: i64) -> String {
950 if secs >= 86400 {
951 format!("{}d", secs / 86400)
952 } else if secs >= 3600 {
953 format!("{}h", secs / 3600)
954 } else if secs >= 60 {
955 format!("{}m", secs / 60)
956 } else {
957 format!("{}s", secs)
958 }
959}
960
961pub fn format_utilization(utilization: Option<f64>) -> String {
963 match utilization {
964 Some(u) => format!("{:.0}%", u),
965 None => "?".into(),
966 }
967}
968
969fn format_usage_bucket(label: &str, bucket: &UsageBucket) -> Option<String> {
971 let pct = format_utilization(bucket.utilization);
972 if pct == "?" {
973 return None;
974 }
975 let reset = bucket
976 .resets_at
977 .as_deref()
978 .map(format_resets_at)
979 .unwrap_or_else(|| "?".into());
980 Some(format!("{} {} {}", label, pct, reset))
981}
982
983pub fn format_subscription_usage(usage: &SubscriptionUsage) -> Option<String> {
989 let mut parts: Vec<String> = Vec::new();
990 if let Some(ref b) = usage.five_hour
991 && let Some(s) = format_usage_bucket("5h", b)
992 {
993 parts.push(s);
994 }
995 if let Some(ref b) = usage.seven_day
996 && let Some(s) = format_usage_bucket("7d", b)
997 {
998 parts.push(s);
999 }
1000 if let Some(ref b) = usage.seven_day_sonnet
1001 && let Some(s) = format_usage_bucket("sonnet", b)
1002 {
1003 parts.push(s);
1004 }
1005 if let Some(ref b) = usage.seven_day_opus
1006 && let Some(s) = format_usage_bucket("opus", b)
1007 {
1008 parts.push(s);
1009 }
1010 if parts.is_empty() {
1011 None
1012 } else {
1013 Some(format!("({})", parts.join(" | ")))
1014 }
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019 use super::*;
1020
1021 #[test]
1022 fn format_tokens_units() {
1023 assert_eq!(format_tokens(0), "0");
1024 assert_eq!(format_tokens(999), "999");
1025 assert_eq!(format_tokens(1_000), "1.0K");
1026 assert_eq!(format_tokens(12_345), "12.3K");
1027 assert_eq!(format_tokens(999_999), "1000.0K");
1028 assert_eq!(format_tokens(1_000_000), "1.0M");
1029 assert_eq!(format_tokens(18_500_000), "18.5M");
1030 }
1031
1032 #[test]
1033 fn format_stats_empty() {
1034 let stats = SessionStats::default();
1035 assert_eq!(format_stats(&stats), "");
1036 }
1037
1038 #[test]
1039 fn format_stats_basic() {
1040 let stats = SessionStats {
1041 tokens: TokenStats {
1042 input: 12_000,
1043 output: 81_000,
1044 cache_read: 18_000_000,
1045 cache_write: 353_000,
1046 },
1047 cost: 13.434,
1048 is_subscription: true,
1049 context_window: 200_000,
1050 context_tokens: Some(36_800),
1051 ..Default::default()
1052 };
1053 let s = format_stats(&stats);
1054 assert!(s.contains("↑12.0K"), "got: {s}");
1055 assert!(s.contains("↓81.0K"), "got: {s}");
1056 assert!(s.contains("R18.0M"), "got: {s}");
1057 assert!(s.contains("W353.0K"), "got: {s}");
1058 assert!(s.contains("$13.434 (sub)"), "got: {s}");
1059 assert!(s.contains("18.4%/200.0K"), "got: {s}");
1060 }
1061
1062 #[test]
1063 fn format_stats_unknown_context() {
1064 let stats = SessionStats {
1065 context_window: 200_000,
1066 context_tokens: None,
1067 ..Default::default()
1068 };
1069 let s = format_stats(&stats);
1070 assert!(s.contains("?/200.0K"), "got: {s}");
1071 }
1072
1073 #[test]
1074 fn format_stats_no_subscription() {
1075 let stats = SessionStats {
1076 tokens: TokenStats {
1077 input: 500,
1078 output: 200,
1079 ..Default::default()
1080 },
1081 cost: 0.005,
1082 is_subscription: false,
1083 ..Default::default()
1084 };
1085 let s = format_stats(&stats);
1086 assert!(s.contains("$0.005"), "got: {s}");
1087 assert!(!s.contains("(sub)"), "got: {s}");
1088 }
1089
1090 #[test]
1091 fn format_subscription_usage_basic() {
1092 use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
1093 let usage = SubscriptionUsage {
1094 five_hour: Some(UsageBucket {
1095 utilization: Some(50.0),
1096 resets_at: Some("2099-01-01T16:00:00Z".into()),
1097 }),
1098 seven_day: Some(UsageBucket {
1099 utilization: Some(12.0),
1100 resets_at: Some("2099-01-03T00:00:00Z".into()),
1101 }),
1102 seven_day_sonnet: Some(UsageBucket {
1103 utilization: Some(6.0),
1104 resets_at: Some("2099-01-02T00:00:00Z".into()),
1105 }),
1106 seven_day_opus: None,
1107 extra_usage: None,
1108 };
1109 let s = format_subscription_usage(&usage).unwrap();
1110 assert!(s.starts_with('('), "got: {s}");
1111 assert!(s.ends_with(')'), "got: {s}");
1112 assert!(s.contains("5h 50%"), "got: {s}");
1113 assert!(s.contains("7d 12%"), "got: {s}");
1114 assert!(s.contains("sonnet 6%"), "got: {s}");
1115 assert!(s.contains(" | "), "got: {s}");
1116 }
1117
1118 #[test]
1119 fn format_subscription_usage_empty() {
1120 use crate::subscription_usage::SubscriptionUsage;
1121 let usage = SubscriptionUsage::default();
1122 assert!(format_subscription_usage(&usage).is_none());
1123 }
1124
1125 #[test]
1126 fn format_subscription_usage_no_utilization() {
1127 use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
1128 let usage = SubscriptionUsage {
1129 five_hour: Some(UsageBucket {
1130 utilization: None,
1131 resets_at: Some("2099-01-01T16:00:00Z".into()),
1132 }),
1133 ..Default::default()
1134 };
1135 assert!(format_subscription_usage(&usage).is_none());
1137 }
1138
1139 #[test]
1140 fn format_duration_compact_units() {
1141 assert_eq!(format_duration_compact(30), "30s");
1142 assert_eq!(format_duration_compact(90), "1m");
1143 assert_eq!(format_duration_compact(3600), "1h");
1144 assert_eq!(format_duration_compact(7200), "2h");
1145 assert_eq!(format_duration_compact(86400), "1d");
1146 assert_eq!(format_duration_compact(172800), "2d");
1147 }
1148
1149 #[test]
1151 fn task_protocol_serde_roundtrip() {
1152 let task = TaskInfo {
1153 id: 42,
1154 project_name: "test-project".into(),
1155 title: "test task".into(),
1156 state: "active".into(),
1157 priority: 5,
1158 parent_id: Some(1),
1159 tags: Some(serde_json::json!(["foo", "bar"])),
1160 affected_files: None,
1161 branch: Some("task-42".into()),
1162 worktree_path: None,
1163 session_id: Some("s123".into()),
1164 skip_review: false,
1165 require_approval: false,
1166 sandbox_profile: None,
1167 held: false,
1168 has_live_session: false,
1169 filed_by_project: None,
1170 filed_by_session_id: None,
1171 created_at: 1000,
1172 updated_at: 2000,
1173 };
1174 let msg = TaskMessageInfo {
1175 id: 1,
1176 task_id: 42,
1177 content: "hello".into(),
1178 author: Some("test".into()),
1179 created_at: 1000,
1180 updated_at: 2000,
1181 };
1182 let rel = TaskRelationInfo {
1183 from_task: 42,
1184 to_task: 43,
1185 relation: "depends_on".into(),
1186 };
1187
1188 let requests: Vec<Request> = vec![
1190 Request::SetTagline {
1191 session_id: "s1".into(),
1192 tagline: "hi".into(),
1193 },
1194 Request::TaskList {
1195 project: "/tmp".into(),
1196 state: Some("active".into()),
1197 parent_id: None,
1198 },
1199 Request::TaskGet { id: 42 },
1200 Request::TaskCreate {
1201 project: "/tmp".into(),
1202 title: "new".into(),
1203 parent_id: None,
1204 priority: Some(3),
1205 tags: vec!["a".into()],
1206 sandbox_profile: None,
1207 },
1208 Request::TaskUpdate {
1209 id: 42,
1210 state: Some("approved".into()),
1211 title: None,
1212 priority: None,
1213 tags: None,
1214 affected_files: None,
1215 skip_review: None,
1216 require_approval: None,
1217 sandbox_profile: None,
1218 },
1219 Request::TaskSearch {
1220 project: "/tmp".into(),
1221 query: "test".into(),
1222 state: None,
1223 },
1224 Request::TaskAssign {
1225 id: 42,
1226 session_id: "s1".into(),
1227 },
1228 Request::TaskStatus {
1229 project: "/tmp".into(),
1230 },
1231 Request::TaskOverview {
1232 project: "/tmp".into(),
1233 recent_limit: 5,
1234 },
1235 Request::TaskMergeQueue {
1236 project: "/tmp".into(),
1237 },
1238 Request::ProjectStats {
1239 project_name: "tau".into(),
1240 },
1241 Request::GetProjectInfo {
1242 project_name: "tau".into(),
1243 },
1244 ];
1245 for req in &requests {
1246 let json = serde_json::to_string(req).expect("serialize request");
1247 let _: Request = serde_json::from_str(&json).expect("deserialize request");
1248 }
1249
1250 let responses: Vec<Response> = vec![
1252 Response::TaskList {
1253 tasks: vec![task.clone()],
1254 },
1255 Response::TaskDetail {
1256 task: task.clone(),
1257 messages: vec![msg],
1258 relations: vec![rel],
1259 subtasks: vec![task.clone()],
1260 sessions: Vec::new(),
1261 history: Vec::new(),
1262 },
1263 Response::TaskUpdated { task: task.clone() },
1264 Response::TaskStatus {
1265 text: "status text".into(),
1266 },
1267 Response::TaskOverview {
1268 active: vec![task.clone()],
1269 queued_ready: Vec::new(),
1270 queued_planning: Vec::new(),
1271 blocked: Vec::new(),
1272 held: Vec::new(),
1273 recently_merged: Vec::new(),
1274 recently_closed: Vec::new(),
1275 inflight_count: 1,
1276 max_concurrent: 8,
1277 wait_reasons: vec![TaskWaitReasons {
1278 task_id: 99,
1279 reasons: vec![
1280 TaskWaitReason::Dependency {
1281 task_id: 42,
1282 title: "dep".into(),
1283 state: "active".into(),
1284 project_name: "tau".into(),
1285 },
1286 TaskWaitReason::BudgetExhausted { used: 8, max: 8 },
1287 ],
1288 }],
1289 },
1290 Response::TaskTree {
1291 tasks: vec![(0, task.clone())],
1292 },
1293 Response::TaskMergeQueue { tasks: vec![task] },
1294 Response::ProjectStats {
1295 stats: ProjectStatsInfo {
1296 project_name: "tau".into(),
1297 session_count: 42,
1298 message_count: 8124,
1299 tokens_input: 12_340_156,
1300 tokens_output: 418_902,
1301 tokens_cache_read: 34_521_088,
1302 tokens_cache_write: 2_108_445,
1303 cost_usd: 28.47,
1304 last_activity: Some(1_700_000_000),
1305 },
1306 },
1307 Response::ProjectInfo {
1308 project: Some(ProjectInfoEntry {
1309 name: "tau".into(),
1310 path: "/home/u/src/tau".into(),
1311 }),
1312 },
1313 Response::ProjectInfo { project: None },
1314 ];
1315 for resp in &responses {
1316 let json = serde_json::to_string(resp).expect("serialize response");
1317 let _: Response = serde_json::from_str(&json).expect("deserialize response");
1318 }
1319 }
1320
1321 #[test]
1322 fn shutting_down_error_round_trips_through_response() {
1323 let err = Response::Error {
1324 message: SHUTTING_DOWN_ERROR.into(),
1325 };
1326 let wire = serde_json::to_string(&err).expect("serialize");
1327 let parsed: Response = serde_json::from_str(&wire).expect("deserialize");
1328 match parsed {
1329 Response::Error { message } => {
1330 assert!(is_shutting_down_error(&message));
1331 }
1332 other => panic!("unexpected variant: {:?}", other),
1333 }
1334
1335 assert!(is_shutting_down_error(SHUTTING_DOWN_ERROR));
1336 assert!(is_shutting_down_error("server is shutting down"));
1337 assert!(!is_shutting_down_error("some other error"));
1338 }
1339
1340 #[test]
1341 fn chat_serialises_without_attachments_when_empty() {
1342 let req = Request::Chat {
1343 session_id: "s1".into(),
1344 text: "hi".into(),
1345 attachments: Vec::new(),
1346 };
1347 let json = serde_json::to_string(&req).expect("serialize");
1348 assert!(
1349 !json.contains("attachments"),
1350 "empty attachments should be omitted from JSON, got: {json}"
1351 );
1352 let parsed: Request = serde_json::from_str(&json).expect("deserialize");
1353 match parsed {
1354 Request::Chat {
1355 session_id,
1356 text,
1357 attachments,
1358 } => {
1359 assert_eq!(session_id, "s1");
1360 assert_eq!(text, "hi");
1361 assert!(attachments.is_empty());
1362 }
1363 other => panic!("unexpected variant: {:?}", other),
1364 }
1365 }
1366
1367 #[test]
1368 fn chat_with_image_roundtrips() {
1369 let req = Request::Chat {
1370 session_id: "s1".into(),
1371 text: "describe".into(),
1372 attachments: vec![ChatAttachment::Image {
1373 data: "AAAA".into(),
1374 mime_type: "image/png".into(),
1375 }],
1376 };
1377 let json = serde_json::to_string(&req).expect("serialize");
1378 assert!(json.contains("\"attachments\""), "got: {json}");
1379 assert!(json.contains("\"type\":\"image\""), "got: {json}");
1380 assert!(json.contains("\"mime_type\":\"image/png\""), "got: {json}");
1381 let parsed: Request = serde_json::from_str(&json).expect("deserialize");
1382 match parsed {
1383 Request::Chat {
1384 session_id,
1385 text,
1386 attachments,
1387 } => {
1388 assert_eq!(session_id, "s1");
1389 assert_eq!(text, "describe");
1390 assert_eq!(attachments.len(), 1);
1391 match &attachments[0] {
1392 ChatAttachment::Image { data, mime_type } => {
1393 assert_eq!(data, "AAAA");
1394 assert_eq!(mime_type, "image/png");
1395 }
1396 }
1397 }
1398 other => panic!("unexpected variant: {:?}", other),
1399 }
1400 }
1401
1402 #[test]
1403 fn legacy_chat_payload_deserialises() {
1404 let json = r#"{"type":"chat","session_id":"s","text":"hi"}"#;
1406 let parsed: Request = serde_json::from_str(json).expect("deserialize legacy");
1407 match parsed {
1408 Request::Chat {
1409 session_id,
1410 text,
1411 attachments,
1412 } => {
1413 assert_eq!(session_id, "s");
1414 assert_eq!(text, "hi");
1415 assert!(attachments.is_empty());
1416 }
1417 other => panic!("unexpected variant: {:?}", other),
1418 }
1419 }
1420}