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 SetSessionSuccessor {
136 session_id: String,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 successor_id: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
142 caller_session_id: Option<String>,
143 },
144 ResolveSuccessor { session_id: String },
150 SucceedSession {
161 session_id: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 tagline: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 caller_session_id: Option<String>,
168 },
169 GetTaskSessionRole { session_id: String },
178 Login { provider: String },
180 AuthStatus,
182 GetSubscriptionUsage,
184 GetMessages { session_id: String },
186 Subscribe { session_id: String },
189 WaitSessions {
191 session_ids: Vec<String>,
192 #[serde(default = "default_wait_timeout")]
193 timeout_secs: u64,
194 },
195 WaitAnySessions {
197 session_ids: Vec<String>,
198 #[serde(default = "default_wait_timeout")]
199 timeout_secs: u64,
200 },
201 CancelChat {
203 session_id: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
208 caller_session_id: Option<String>,
209 },
210 Steer { session_id: String, text: String },
214 Compact {
218 session_id: String,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 keep_hint: Option<String>,
221 },
222
223 QueueMessage {
227 target_session_id: String,
228 content: String,
229 sender_info: String,
230 #[serde(default)]
232 await_reply: bool,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 reply_to: Option<String>,
236 },
237 QueueInfo {
244 target_session_id: String,
245 text: String,
246 },
247 ReplyToMessage { msg_id: String, content: String },
249 ReloadPlugins { session_id: String },
251 ReloadConfig,
261 GcSessions {
263 older_than_days: u64,
265 },
266 FireHook {
268 name: String,
269 data: serde_json::Value,
270 },
271 ExecuteTool {
273 session_id: String,
274 tool_name: String,
275 arguments: serde_json::Value,
276 },
277 EnqueuePostIdleAction {
284 session_id: String,
285 action: crate::types::PostIdleAction,
286 },
287 SetTagline { session_id: String, tagline: String },
289 TaskList {
291 project: String,
292 #[serde(skip_serializing_if = "Option::is_none")]
293 state: Option<String>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 parent_id: Option<i64>,
296 },
297 TaskGet { id: i64 },
299 TaskCreate {
301 project: String,
302 title: String,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 parent_id: Option<i64>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 priority: Option<i32>,
307 #[serde(default)]
308 tags: Vec<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 sandbox_profile: Option<String>,
311 },
312 TaskUpdate {
314 id: i64,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 state: Option<String>,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 title: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 priority: Option<i64>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 tags: Option<serde_json::Value>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 affected_files: Option<serde_json::Value>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 skip_review: Option<bool>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 require_approval: Option<bool>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 sandbox_profile: Option<String>,
331 },
332 TaskSearch {
334 project: String,
335 query: String,
336 #[serde(skip_serializing_if = "Option::is_none")]
337 state: Option<String>,
338 },
339 TaskAssign { id: i64, session_id: String },
341 TaskStatus { project: String },
343 TaskOverview {
354 project: String,
355 #[serde(default = "default_recent_limit")]
358 recent_limit: usize,
359 },
360 TaskMergeQueue { project: String },
362 ProjectStats { project_name: String },
367 GetProjectInfo { project_name: String },
372 Shutdown {
374 #[serde(default)]
376 restart: bool,
377 },
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
385#[serde(tag = "type", rename_all = "snake_case")]
386pub enum ChatAttachment {
387 Image { data: String, mime_type: String },
393}
394
395impl ChatAttachment {
396 pub fn to_user_content(&self) -> crate::types::UserContent {
401 match self {
402 ChatAttachment::Image { data, mime_type } => {
403 crate::types::UserContent::Image(crate::types::ImageContent {
404 data: data.clone(),
405 mime_type: mime_type.clone(),
406 })
407 }
408 }
409 }
410}
411
412#[derive(Debug, Serialize, Deserialize, Clone)]
417#[serde(tag = "type", rename_all = "snake_case")]
418pub enum Response {
419 SessionCreated { session_id: String },
421 SessionInfo { info: SessionInfo },
423 SessionAncestors { sessions: Vec<SessionInfo> },
425 Sessions { sessions: Vec<SessionInfo> },
427 SessionDeleted,
429 SessionArchived,
431 SessionRestored,
433 Models { models: Vec<ModelInfo> },
435 Aliases {
440 #[serde(default)]
441 global: Vec<AliasInfo>,
442 #[serde(default)]
443 project: Vec<AliasInfo>,
444 },
445 ModelChanged { model: ModelInfo },
447 Stream { event: Box<StreamEvent> },
449 LoginSuccess { provider: String },
451 AuthStatus { providers: Vec<String> },
453 SubscriptionUsage { usage: SubscriptionUsage },
455 ServerShutdown { restart: bool },
457 SessionsCompleted { results: Vec<SessionResult> },
459 Cancelled,
461 Messages {
463 messages: Vec<crate::types::Message>,
464 },
465 UserMessage { text: String },
467 AgentDone,
469 MessageReply { content: String },
471 Ok,
473 OkWithNote { note: String },
485 GcComplete { deleted: usize },
487 ToolExecuted { content: String, is_error: bool },
489 TaskList { tasks: Vec<TaskInfo> },
491 TaskDetail {
493 task: TaskInfo,
494 messages: Vec<TaskMessageInfo>,
495 relations: Vec<TaskRelationInfo>,
496 subtasks: Vec<TaskInfo>,
497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
502 sessions: Vec<TaskSessionInfo>,
503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
505 history: Vec<TaskHistoryInfo>,
506 },
507 TaskUpdated { task: TaskInfo },
509 TaskStatus { text: String },
511 TaskOverview {
513 active: Vec<TaskInfo>,
515 queued_ready: Vec<TaskInfo>,
517 queued_planning: Vec<TaskInfo>,
519 blocked: Vec<TaskInfo>,
521 held: Vec<TaskInfo>,
523 recently_merged: Vec<TaskInfo>,
526 #[serde(default)]
529 recently_done: Vec<TaskInfo>,
530 recently_closed: Vec<TaskInfo>,
533 inflight_count: usize,
535 max_concurrent: usize,
537 #[serde(default, skip_serializing_if = "Vec::is_empty")]
542 wait_reasons: Vec<TaskWaitReasons>,
543 },
544 TaskTree { tasks: Vec<(usize, TaskInfo)> },
546 TaskMergeQueue { tasks: Vec<TaskInfo> },
548 ProjectStats { stats: ProjectStatsInfo },
550 ProjectInfo { project: Option<ProjectInfoEntry> },
556 ResolvedSuccessor { session_id: String },
561 SessionSucceeded { successor_id: String },
566 TaskSessionRole {
572 is_worker: bool,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 task_id: Option<i64>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 role: Option<String>,
577 },
578 Error { message: String },
580}
581
582pub const SHUTTING_DOWN_ERROR: &str = "__tau_server_shutting_down__";
591
592pub fn is_shutting_down_error(err: &str) -> bool {
597 err == SHUTTING_DOWN_ERROR || err.contains("server is shutting down")
598}
599
600pub fn is_subscription_usage_error(err: &str) -> bool {
610 err.contains("usage API")
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize)]
614pub struct SessionInfo {
615 pub id: String,
616 pub model: String,
617 pub provider: String,
618 pub cwd: Option<String>,
619 pub message_count: usize,
620 pub stats: SessionStats,
621 pub last_activity: i64,
623 #[serde(skip_serializing_if = "Option::is_none")]
625 pub parent_id: Option<String>,
626 #[serde(default)]
628 pub child_count: usize,
629 #[serde(default)]
631 pub child_budget: u32,
632 #[serde(skip_serializing_if = "Option::is_none")]
634 pub tagline: Option<String>,
635 #[serde(default = "default_state")]
637 pub state: String,
638 #[serde(skip_serializing_if = "Option::is_none")]
640 pub context_pct: Option<f64>,
641 #[serde(default)]
643 pub archived: bool,
644 #[serde(skip_serializing_if = "Option::is_none")]
646 pub project_name: Option<String>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub successor_id: Option<String>,
652 #[serde(skip_serializing_if = "Option::is_none")]
654 pub last_exit_status: Option<String>,
655 #[serde(default)]
659 pub is_live: bool,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub turn_started_at_ms: Option<u64>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub phase_started_at_ms: Option<u64>,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct SessionResult {
678 pub session_id: String,
679 pub status: String,
681 pub summary: String,
683}
684
685fn default_wait_timeout() -> u64 {
686 300
687}
688
689fn default_true() -> bool {
690 true
691}
692
693fn default_state() -> String {
694 "idle".into()
695}
696
697fn default_recent_limit() -> usize {
698 10
699}
700
701#[derive(Debug, Clone, Serialize, Deserialize)]
702pub struct ModelInfo {
703 pub id: String,
704 pub name: String,
705 pub provider: String,
706 pub thinking: crate::types::ThinkingStyle,
707 pub context_window: u64,
708 pub max_tokens: u64,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct AliasInfo {
717 pub name: String,
719 pub target: String,
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct TaskInfo {
727 pub id: i64,
728 pub project_name: String,
729 pub title: String,
730 pub state: String,
731 pub priority: i64,
732 pub parent_id: Option<i64>,
733 pub tags: Option<serde_json::Value>,
734 pub affected_files: Option<serde_json::Value>,
735 pub branch: Option<String>,
736 pub worktree_path: Option<String>,
737 pub session_id: Option<String>,
738 pub skip_review: bool,
739 pub require_approval: bool,
740 #[serde(skip_serializing_if = "Option::is_none")]
741 pub sandbox_profile: Option<String>,
742 #[serde(default)]
743 pub held: bool,
744 #[serde(default)]
749 pub has_live_session: bool,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub filed_by_project: Option<String>,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub filed_by_session_id: Option<String>,
763 #[serde(default)]
768 pub no_merge: bool,
769 pub created_at: i64,
770 pub updated_at: i64,
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
775pub struct TaskMessageInfo {
776 pub id: i64,
777 pub task_id: i64,
778 pub content: String,
779 pub author: Option<String>,
780 pub created_at: i64,
781 pub updated_at: i64,
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct TaskRelationInfo {
787 pub from_task: i64,
788 pub to_task: i64,
789 pub relation: String,
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct TaskSessionInfo {
800 pub session_id: String,
802 pub role: String,
804 pub created_at: i64,
806
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub message_count: Option<usize>,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub archived: Option<bool>,
811 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub last_activity: Option<i64>,
814 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub last_phase: Option<String>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub last_exit_status: Option<String>,
822 #[serde(default)]
824 pub is_live: bool,
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct TaskWaitReasons {
834 pub task_id: i64,
835 pub reasons: Vec<TaskWaitReason>,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
841pub enum TaskWaitReason {
842 Dependency {
844 task_id: i64,
845 title: String,
846 state: String,
847 project_name: String,
848 },
849 FileConflict {
851 files: Vec<String>,
852 with_task_id: i64,
853 },
854 BudgetExhausted { used: usize, max: usize },
856 MergeTargetNotFound { branch: String },
858 NotScheduled,
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
865pub struct TaskHistoryInfo {
866 pub field: String,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub old_value: Option<String>,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
872 pub new_value: Option<String>,
873 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub session_id: Option<String>,
876 pub created_at: i64,
878}
879
880#[derive(Debug, Clone, Default, Serialize, Deserialize)]
882pub struct SessionStats {
883 pub user_messages: usize,
884 pub assistant_messages: usize,
885 pub tool_calls: usize,
886 pub tool_results: usize,
887 pub tokens: TokenStats,
888 pub cost: f64,
889 pub is_subscription: bool,
891 pub context_window: u64,
893 pub context_tokens: Option<u64>,
895}
896
897#[derive(Debug, Clone, Default, Serialize, Deserialize)]
898pub struct TokenStats {
899 pub input: u64,
900 pub output: u64,
901 pub cache_read: u64,
902 pub cache_write: u64,
903}
904
905impl TokenStats {
906 pub fn total(&self) -> u64 {
907 self.input + self.output + self.cache_read + self.cache_write
908 }
909}
910
911#[derive(Debug, Clone, Default, Serialize, Deserialize)]
916pub struct ProjectStatsInfo {
917 pub project_name: String,
918 pub session_count: usize,
919 pub message_count: usize,
920 pub tokens_input: u64,
921 pub tokens_output: u64,
922 pub tokens_cache_read: u64,
923 pub tokens_cache_write: u64,
924 pub cost_usd: f64,
925 #[serde(skip_serializing_if = "Option::is_none")]
928 pub last_activity: Option<i64>,
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize)]
937pub struct ProjectInfoEntry {
938 pub name: String,
939 pub path: String,
940}
941
942pub fn format_tokens(n: u64) -> String {
944 if n >= 1_000_000 {
945 format!("{:.1}M", n as f64 / 1_000_000.0)
946 } else if n >= 1_000 {
947 format!("{:.1}K", n as f64 / 1_000.0)
948 } else {
949 n.to_string()
950 }
951}
952
953#[allow(clippy::cast_precision_loss)]
956pub fn format_stats(stats: &SessionStats) -> String {
957 let mut parts = Vec::new();
958
959 if stats.tokens.input > 0 {
960 parts.push(format!("↑{}", format_tokens(stats.tokens.input)));
961 }
962 if stats.tokens.output > 0 {
963 parts.push(format!("↓{}", format_tokens(stats.tokens.output)));
964 }
965 if stats.tokens.cache_read > 0 {
966 parts.push(format!("R{}", format_tokens(stats.tokens.cache_read)));
967 }
968 if stats.tokens.cache_write > 0 {
969 parts.push(format!("W{}", format_tokens(stats.tokens.cache_write)));
970 }
971
972 let cost_str = if stats.is_subscription {
973 format!("${:.3} (sub)", stats.cost)
974 } else if stats.cost > 0.0 {
975 format!("${:.3}", stats.cost)
976 } else {
977 String::new()
978 };
979 if !cost_str.is_empty() {
980 parts.push(cost_str);
981 }
982
983 if stats.context_window > 0 {
984 let ctx = match stats.context_tokens {
985 Some(t) => {
986 let pct = (t as f64 / stats.context_window as f64) * 100.0;
987 format!("{:.1}%/{}", pct, format_tokens(stats.context_window))
988 }
989 None => format!("?/{}", format_tokens(stats.context_window)),
990 };
991 parts.push(ctx);
992 }
993
994 parts.join(" ")
995}
996
997fn format_resets_at(resets_at: &str) -> String {
1000 let trimmed = resets_at.trim().trim_end_matches('Z');
1003 let (date_part, time_part) = match trimmed.split_once('T') {
1004 Some(pair) => pair,
1005 None => return "?".into(),
1006 };
1007 let mut date_iter = date_part.split('-');
1008 let year: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1009 let month: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1010 let day: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1011
1012 let time_clean = time_part
1014 .split('+')
1015 .next()
1016 .unwrap_or(time_part)
1017 .split('.')
1018 .next()
1019 .unwrap_or(time_part);
1020 let mut time_iter = time_clean.split(':');
1021 let hour: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1022 let minute: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1023 let second: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
1024
1025 fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
1027 let y = if m <= 2 { y - 1 } else { y };
1028 let era = y.div_euclid(400);
1029 let yoe = y.rem_euclid(400);
1030 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
1031 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1032 era * 146097 + doe - 719468
1033 }
1034 let reset_epoch =
1035 days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second;
1036
1037 let now = std::time::SystemTime::now()
1038 .duration_since(std::time::UNIX_EPOCH)
1039 .unwrap_or_default()
1040 .as_secs() as i64;
1041
1042 let delta = reset_epoch - now;
1043 if delta <= 0 {
1044 return "?".into();
1045 }
1046 format_duration_compact(delta)
1047}
1048
1049fn format_duration_compact(secs: i64) -> String {
1051 if secs >= 86400 {
1052 format!("{}d", secs / 86400)
1053 } else if secs >= 3600 {
1054 format!("{}h", secs / 3600)
1055 } else if secs >= 60 {
1056 format!("{}m", secs / 60)
1057 } else {
1058 format!("{}s", secs)
1059 }
1060}
1061
1062pub fn format_utilization(utilization: Option<f64>) -> String {
1064 match utilization {
1065 Some(u) => format!("{:.0}%", u),
1066 None => "?".into(),
1067 }
1068}
1069
1070fn format_usage_bucket(label: &str, bucket: &UsageBucket) -> Option<String> {
1072 let pct = format_utilization(bucket.utilization);
1073 if pct == "?" {
1074 return None;
1075 }
1076 let reset = bucket
1077 .resets_at
1078 .as_deref()
1079 .map(format_resets_at)
1080 .unwrap_or_else(|| "?".into());
1081 Some(format!("{} {} {}", label, pct, reset))
1082}
1083
1084pub fn format_subscription_usage(usage: &SubscriptionUsage) -> Option<String> {
1090 let mut parts: Vec<String> = Vec::new();
1091 if let Some(ref b) = usage.five_hour
1092 && let Some(s) = format_usage_bucket("5h", b)
1093 {
1094 parts.push(s);
1095 }
1096 if let Some(ref b) = usage.seven_day
1097 && let Some(s) = format_usage_bucket("7d", b)
1098 {
1099 parts.push(s);
1100 }
1101 if let Some(ref b) = usage.seven_day_sonnet
1102 && let Some(s) = format_usage_bucket("sonnet", b)
1103 {
1104 parts.push(s);
1105 }
1106 if let Some(ref b) = usage.seven_day_opus
1107 && let Some(s) = format_usage_bucket("opus", b)
1108 {
1109 parts.push(s);
1110 }
1111 if parts.is_empty() {
1112 None
1113 } else {
1114 Some(format!("({})", parts.join(" | ")))
1115 }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121
1122 #[test]
1123 fn format_tokens_units() {
1124 assert_eq!(format_tokens(0), "0");
1125 assert_eq!(format_tokens(999), "999");
1126 assert_eq!(format_tokens(1_000), "1.0K");
1127 assert_eq!(format_tokens(12_345), "12.3K");
1128 assert_eq!(format_tokens(999_999), "1000.0K");
1129 assert_eq!(format_tokens(1_000_000), "1.0M");
1130 assert_eq!(format_tokens(18_500_000), "18.5M");
1131 }
1132
1133 #[test]
1134 fn format_stats_empty() {
1135 let stats = SessionStats::default();
1136 assert_eq!(format_stats(&stats), "");
1137 }
1138
1139 #[test]
1140 fn format_stats_basic() {
1141 let stats = SessionStats {
1142 tokens: TokenStats {
1143 input: 12_000,
1144 output: 81_000,
1145 cache_read: 18_000_000,
1146 cache_write: 353_000,
1147 },
1148 cost: 13.434,
1149 is_subscription: true,
1150 context_window: 200_000,
1151 context_tokens: Some(36_800),
1152 ..Default::default()
1153 };
1154 let s = format_stats(&stats);
1155 assert!(s.contains("↑12.0K"), "got: {s}");
1156 assert!(s.contains("↓81.0K"), "got: {s}");
1157 assert!(s.contains("R18.0M"), "got: {s}");
1158 assert!(s.contains("W353.0K"), "got: {s}");
1159 assert!(s.contains("$13.434 (sub)"), "got: {s}");
1160 assert!(s.contains("18.4%/200.0K"), "got: {s}");
1161 }
1162
1163 #[test]
1164 fn format_stats_unknown_context() {
1165 let stats = SessionStats {
1166 context_window: 200_000,
1167 context_tokens: None,
1168 ..Default::default()
1169 };
1170 let s = format_stats(&stats);
1171 assert!(s.contains("?/200.0K"), "got: {s}");
1172 }
1173
1174 #[test]
1175 fn format_stats_no_subscription() {
1176 let stats = SessionStats {
1177 tokens: TokenStats {
1178 input: 500,
1179 output: 200,
1180 ..Default::default()
1181 },
1182 cost: 0.005,
1183 is_subscription: false,
1184 ..Default::default()
1185 };
1186 let s = format_stats(&stats);
1187 assert!(s.contains("$0.005"), "got: {s}");
1188 assert!(!s.contains("(sub)"), "got: {s}");
1189 }
1190
1191 #[test]
1192 fn format_subscription_usage_basic() {
1193 use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
1194 let usage = SubscriptionUsage {
1195 five_hour: Some(UsageBucket {
1196 utilization: Some(50.0),
1197 resets_at: Some("2099-01-01T16:00:00Z".into()),
1198 }),
1199 seven_day: Some(UsageBucket {
1200 utilization: Some(12.0),
1201 resets_at: Some("2099-01-03T00:00:00Z".into()),
1202 }),
1203 seven_day_sonnet: Some(UsageBucket {
1204 utilization: Some(6.0),
1205 resets_at: Some("2099-01-02T00:00:00Z".into()),
1206 }),
1207 seven_day_opus: None,
1208 extra_usage: None,
1209 };
1210 let s = format_subscription_usage(&usage).unwrap();
1211 assert!(s.starts_with('('), "got: {s}");
1212 assert!(s.ends_with(')'), "got: {s}");
1213 assert!(s.contains("5h 50%"), "got: {s}");
1214 assert!(s.contains("7d 12%"), "got: {s}");
1215 assert!(s.contains("sonnet 6%"), "got: {s}");
1216 assert!(s.contains(" | "), "got: {s}");
1217 }
1218
1219 #[test]
1220 fn format_subscription_usage_empty() {
1221 use crate::subscription_usage::SubscriptionUsage;
1222 let usage = SubscriptionUsage::default();
1223 assert!(format_subscription_usage(&usage).is_none());
1224 }
1225
1226 #[test]
1227 fn format_subscription_usage_no_utilization() {
1228 use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
1229 let usage = SubscriptionUsage {
1230 five_hour: Some(UsageBucket {
1231 utilization: None,
1232 resets_at: Some("2099-01-01T16:00:00Z".into()),
1233 }),
1234 ..Default::default()
1235 };
1236 assert!(format_subscription_usage(&usage).is_none());
1238 }
1239
1240 #[test]
1241 fn format_duration_compact_units() {
1242 assert_eq!(format_duration_compact(30), "30s");
1243 assert_eq!(format_duration_compact(90), "1m");
1244 assert_eq!(format_duration_compact(3600), "1h");
1245 assert_eq!(format_duration_compact(7200), "2h");
1246 assert_eq!(format_duration_compact(86400), "1d");
1247 assert_eq!(format_duration_compact(172800), "2d");
1248 }
1249
1250 #[test]
1252 fn task_protocol_serde_roundtrip() {
1253 let task = TaskInfo {
1254 id: 42,
1255 project_name: "test-project".into(),
1256 title: "test task".into(),
1257 state: "active".into(),
1258 priority: 5,
1259 parent_id: Some(1),
1260 tags: Some(serde_json::json!(["foo", "bar"])),
1261 affected_files: None,
1262 branch: Some("task-42".into()),
1263 worktree_path: None,
1264 session_id: Some("s123".into()),
1265 skip_review: false,
1266 require_approval: false,
1267 sandbox_profile: None,
1268 held: false,
1269 has_live_session: false,
1270 filed_by_project: None,
1271 filed_by_session_id: None,
1272 no_merge: false,
1273 created_at: 1000,
1274 updated_at: 2000,
1275 };
1276 let msg = TaskMessageInfo {
1277 id: 1,
1278 task_id: 42,
1279 content: "hello".into(),
1280 author: Some("test".into()),
1281 created_at: 1000,
1282 updated_at: 2000,
1283 };
1284 let rel = TaskRelationInfo {
1285 from_task: 42,
1286 to_task: 43,
1287 relation: "depends_on".into(),
1288 };
1289
1290 let requests: Vec<Request> = vec![
1292 Request::SetTagline {
1293 session_id: "s1".into(),
1294 tagline: "hi".into(),
1295 },
1296 Request::TaskList {
1297 project: "/tmp".into(),
1298 state: Some("active".into()),
1299 parent_id: None,
1300 },
1301 Request::TaskGet { id: 42 },
1302 Request::TaskCreate {
1303 project: "/tmp".into(),
1304 title: "new".into(),
1305 parent_id: None,
1306 priority: Some(3),
1307 tags: vec!["a".into()],
1308 sandbox_profile: None,
1309 },
1310 Request::TaskUpdate {
1311 id: 42,
1312 state: Some("approved".into()),
1313 title: None,
1314 priority: None,
1315 tags: None,
1316 affected_files: None,
1317 skip_review: None,
1318 require_approval: None,
1319 sandbox_profile: None,
1320 },
1321 Request::TaskSearch {
1322 project: "/tmp".into(),
1323 query: "test".into(),
1324 state: None,
1325 },
1326 Request::TaskAssign {
1327 id: 42,
1328 session_id: "s1".into(),
1329 },
1330 Request::TaskStatus {
1331 project: "/tmp".into(),
1332 },
1333 Request::TaskOverview {
1334 project: "/tmp".into(),
1335 recent_limit: 5,
1336 },
1337 Request::TaskMergeQueue {
1338 project: "/tmp".into(),
1339 },
1340 Request::ProjectStats {
1341 project_name: "tau".into(),
1342 },
1343 Request::GetProjectInfo {
1344 project_name: "tau".into(),
1345 },
1346 Request::SetSessionSuccessor {
1347 session_id: "s1".into(),
1348 successor_id: Some("s2".into()),
1349 caller_session_id: None,
1350 },
1351 Request::SetSessionSuccessor {
1352 session_id: "s1".into(),
1353 successor_id: None,
1354 caller_session_id: Some("caller".into()),
1355 },
1356 Request::ResolveSuccessor {
1357 session_id: "s1".into(),
1358 },
1359 Request::SucceedSession {
1360 session_id: "s1".into(),
1361 tagline: Some("continued".into()),
1362 caller_session_id: Some("caller".into()),
1363 },
1364 Request::SucceedSession {
1365 session_id: "s1".into(),
1366 tagline: None,
1367 caller_session_id: None,
1368 },
1369 Request::GetTaskSessionRole {
1370 session_id: "s1".into(),
1371 },
1372 ];
1373 for req in &requests {
1374 let json = serde_json::to_string(req).expect("serialize request");
1375 let _: Request = serde_json::from_str(&json).expect("deserialize request");
1376 }
1377
1378 let responses: Vec<Response> = vec![
1380 Response::TaskList {
1381 tasks: vec![task.clone()],
1382 },
1383 Response::TaskDetail {
1384 task: task.clone(),
1385 messages: vec![msg],
1386 relations: vec![rel],
1387 subtasks: vec![task.clone()],
1388 sessions: Vec::new(),
1389 history: Vec::new(),
1390 },
1391 Response::TaskUpdated { task: task.clone() },
1392 Response::TaskStatus {
1393 text: "status text".into(),
1394 },
1395 Response::TaskOverview {
1396 active: vec![task.clone()],
1397 queued_ready: Vec::new(),
1398 queued_planning: Vec::new(),
1399 blocked: Vec::new(),
1400 held: Vec::new(),
1401 recently_merged: Vec::new(),
1402 recently_done: Vec::new(),
1403 recently_closed: Vec::new(),
1404 inflight_count: 1,
1405 max_concurrent: 8,
1406 wait_reasons: vec![TaskWaitReasons {
1407 task_id: 99,
1408 reasons: vec![
1409 TaskWaitReason::Dependency {
1410 task_id: 42,
1411 title: "dep".into(),
1412 state: "active".into(),
1413 project_name: "tau".into(),
1414 },
1415 TaskWaitReason::BudgetExhausted { used: 8, max: 8 },
1416 ],
1417 }],
1418 },
1419 Response::TaskTree {
1420 tasks: vec![(0, task.clone())],
1421 },
1422 Response::TaskMergeQueue { tasks: vec![task] },
1423 Response::ProjectStats {
1424 stats: ProjectStatsInfo {
1425 project_name: "tau".into(),
1426 session_count: 42,
1427 message_count: 8124,
1428 tokens_input: 12_340_156,
1429 tokens_output: 418_902,
1430 tokens_cache_read: 34_521_088,
1431 tokens_cache_write: 2_108_445,
1432 cost_usd: 28.47,
1433 last_activity: Some(1_700_000_000),
1434 },
1435 },
1436 Response::ProjectInfo {
1437 project: Some(ProjectInfoEntry {
1438 name: "tau".into(),
1439 path: "/home/u/src/tau".into(),
1440 }),
1441 },
1442 Response::ProjectInfo { project: None },
1443 Response::ResolvedSuccessor {
1444 session_id: "s1".into(),
1445 },
1446 Response::SessionSucceeded {
1447 successor_id: "s2".into(),
1448 },
1449 Response::TaskSessionRole {
1450 is_worker: true,
1451 task_id: Some(42),
1452 role: Some("worker".into()),
1453 },
1454 Response::TaskSessionRole {
1455 is_worker: false,
1456 task_id: None,
1457 role: None,
1458 },
1459 ];
1460 for resp in &responses {
1461 let json = serde_json::to_string(resp).expect("serialize response");
1462 let _: Response = serde_json::from_str(&json).expect("deserialize response");
1463 }
1464 }
1465
1466 #[test]
1467 fn shutting_down_error_round_trips_through_response() {
1468 let err = Response::Error {
1469 message: SHUTTING_DOWN_ERROR.into(),
1470 };
1471 let wire = serde_json::to_string(&err).expect("serialize");
1472 let parsed: Response = serde_json::from_str(&wire).expect("deserialize");
1473 match parsed {
1474 Response::Error { message } => {
1475 assert!(is_shutting_down_error(&message));
1476 }
1477 other => panic!("unexpected variant: {:?}", other),
1478 }
1479
1480 assert!(is_shutting_down_error(SHUTTING_DOWN_ERROR));
1481 assert!(is_shutting_down_error("server is shutting down"));
1482 assert!(!is_shutting_down_error("some other error"));
1483 }
1484
1485 #[test]
1486 fn chat_serialises_without_attachments_when_empty() {
1487 let req = Request::Chat {
1488 session_id: "s1".into(),
1489 text: "hi".into(),
1490 attachments: Vec::new(),
1491 };
1492 let json = serde_json::to_string(&req).expect("serialize");
1493 assert!(
1494 !json.contains("attachments"),
1495 "empty attachments should be omitted from JSON, got: {json}"
1496 );
1497 let parsed: Request = serde_json::from_str(&json).expect("deserialize");
1498 match parsed {
1499 Request::Chat {
1500 session_id,
1501 text,
1502 attachments,
1503 } => {
1504 assert_eq!(session_id, "s1");
1505 assert_eq!(text, "hi");
1506 assert!(attachments.is_empty());
1507 }
1508 other => panic!("unexpected variant: {:?}", other),
1509 }
1510 }
1511
1512 #[test]
1513 fn chat_with_image_roundtrips() {
1514 let req = Request::Chat {
1515 session_id: "s1".into(),
1516 text: "describe".into(),
1517 attachments: vec![ChatAttachment::Image {
1518 data: "AAAA".into(),
1519 mime_type: "image/png".into(),
1520 }],
1521 };
1522 let json = serde_json::to_string(&req).expect("serialize");
1523 assert!(json.contains("\"attachments\""), "got: {json}");
1524 assert!(json.contains("\"type\":\"image\""), "got: {json}");
1525 assert!(json.contains("\"mime_type\":\"image/png\""), "got: {json}");
1526 let parsed: Request = serde_json::from_str(&json).expect("deserialize");
1527 match parsed {
1528 Request::Chat {
1529 session_id,
1530 text,
1531 attachments,
1532 } => {
1533 assert_eq!(session_id, "s1");
1534 assert_eq!(text, "describe");
1535 assert_eq!(attachments.len(), 1);
1536 match &attachments[0] {
1537 ChatAttachment::Image { data, mime_type } => {
1538 assert_eq!(data, "AAAA");
1539 assert_eq!(mime_type, "image/png");
1540 }
1541 }
1542 }
1543 other => panic!("unexpected variant: {:?}", other),
1544 }
1545 }
1546
1547 #[test]
1548 fn legacy_chat_payload_deserialises() {
1549 let json = r#"{"type":"chat","session_id":"s","text":"hi"}"#;
1551 let parsed: Request = serde_json::from_str(json).expect("deserialize legacy");
1552 match parsed {
1553 Request::Chat {
1554 session_id,
1555 text,
1556 attachments,
1557 } => {
1558 assert_eq!(session_id, "s");
1559 assert_eq!(text, "hi");
1560 assert!(attachments.is_empty());
1561 }
1562 other => panic!("unexpected variant: {:?}", other),
1563 }
1564 }
1565}