use serde::{Deserialize, Serialize};
use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
use crate::types::StreamEvent;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Request {
Chat {
session_id: String,
text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<ChatAttachment>,
},
CreateSession {
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<String>,
#[serde(default)]
child_budget: u32,
#[serde(skip_serializing_if = "Option::is_none")]
tagline: Option<String>,
#[serde(default)]
auto_archive: bool,
#[serde(default = "default_true")]
notify_parent: bool,
#[serde(skip_serializing_if = "Option::is_none")]
project_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sandbox_profile: Option<String>,
},
GetSessionInfo { session_id: String },
GetSessionAncestors { session_id: String },
ListSessions {
#[serde(default)]
include_archived: bool,
#[serde(skip_serializing_if = "Option::is_none")]
project_name: Option<String>,
},
ArchiveSession {
session_id: String,
#[serde(default)]
require_ancestor: Option<String>,
},
RestoreSession { session_id: String },
DeleteSession { session_id: String },
ListModels,
ListAliases {
#[serde(default)]
cwd: Option<String>,
},
SetModel {
session_id: String,
model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
caller_session_id: Option<String>,
},
SetCwd {
session_id: String,
cwd: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
caller_session_id: Option<String>,
},
ReparentChildren {
old_parent_id: String,
new_parent_id: String,
},
Login { provider: String },
AuthStatus,
GetSubscriptionUsage,
GetMessages { session_id: String },
Subscribe { session_id: String },
WaitSessions {
session_ids: Vec<String>,
#[serde(default = "default_wait_timeout")]
timeout_secs: u64,
},
WaitAnySessions {
session_ids: Vec<String>,
#[serde(default = "default_wait_timeout")]
timeout_secs: u64,
},
CancelChat {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
caller_session_id: Option<String>,
},
Steer { session_id: String, text: String },
Compact {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
keep_hint: Option<String>,
},
QueueMessage {
target_session_id: String,
content: String,
sender_info: String,
#[serde(default)]
await_reply: bool,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<String>,
},
QueueInfo {
target_session_id: String,
text: String,
},
ReplyToMessage { msg_id: String, content: String },
ReloadPlugins { session_id: String },
ReloadConfig,
GcSessions {
older_than_days: u64,
},
FireHook {
name: String,
data: serde_json::Value,
},
ExecuteTool {
session_id: String,
tool_name: String,
arguments: serde_json::Value,
},
EnqueuePostIdleAction {
session_id: String,
action: crate::types::PostIdleAction,
},
SetTagline { session_id: String, tagline: String },
TaskList {
project: String,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<i64>,
},
TaskGet { id: i64 },
TaskCreate {
project: String,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
priority: Option<i32>,
#[serde(default)]
tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sandbox_profile: Option<String>,
},
TaskUpdate {
id: i64,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
priority: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
affected_files: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
skip_review: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
require_approval: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
sandbox_profile: Option<String>,
},
TaskSearch {
project: String,
query: String,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<String>,
},
TaskAssign { id: i64, session_id: String },
TaskStatus { project: String },
TaskOverview {
project: String,
#[serde(default = "default_recent_limit")]
recent_limit: usize,
},
TaskMergeQueue { project: String },
ProjectStats { project_name: String },
GetProjectInfo { project_name: String },
Shutdown {
#[serde(default)]
restart: bool,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChatAttachment {
Image { data: String, mime_type: String },
}
impl ChatAttachment {
pub fn to_user_content(&self) -> crate::types::UserContent {
match self {
ChatAttachment::Image { data, mime_type } => {
crate::types::UserContent::Image(crate::types::ImageContent {
data: data.clone(),
mime_type: mime_type.clone(),
})
}
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Response {
SessionCreated { session_id: String },
SessionInfo { info: SessionInfo },
SessionAncestors { sessions: Vec<SessionInfo> },
Sessions { sessions: Vec<SessionInfo> },
SessionDeleted,
SessionArchived,
SessionRestored,
Models { models: Vec<ModelInfo> },
Aliases {
#[serde(default)]
global: Vec<AliasInfo>,
#[serde(default)]
project: Vec<AliasInfo>,
},
ModelChanged { model: ModelInfo },
Stream { event: Box<StreamEvent> },
LoginSuccess { provider: String },
AuthStatus { providers: Vec<String> },
SubscriptionUsage { usage: SubscriptionUsage },
ServerShutdown { restart: bool },
SessionsCompleted { results: Vec<SessionResult> },
Cancelled,
Messages {
messages: Vec<crate::types::Message>,
},
UserMessage { text: String },
AgentDone,
MessageReply { content: String },
Ok,
OkWithNote { note: String },
GcComplete { deleted: usize },
ToolExecuted { content: String, is_error: bool },
TaskList { tasks: Vec<TaskInfo> },
TaskDetail {
task: TaskInfo,
messages: Vec<TaskMessageInfo>,
relations: Vec<TaskRelationInfo>,
subtasks: Vec<TaskInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
sessions: Vec<TaskSessionInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
history: Vec<TaskHistoryInfo>,
},
TaskUpdated { task: TaskInfo },
TaskStatus { text: String },
TaskOverview {
active: Vec<TaskInfo>,
queued_ready: Vec<TaskInfo>,
queued_planning: Vec<TaskInfo>,
blocked: Vec<TaskInfo>,
held: Vec<TaskInfo>,
recently_merged: Vec<TaskInfo>,
recently_closed: Vec<TaskInfo>,
inflight_count: usize,
max_concurrent: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
wait_reasons: Vec<TaskWaitReasons>,
},
TaskTree { tasks: Vec<(usize, TaskInfo)> },
TaskMergeQueue { tasks: Vec<TaskInfo> },
ProjectStats { stats: ProjectStatsInfo },
ProjectInfo { project: Option<ProjectInfoEntry> },
Error { message: String },
}
pub const SHUTTING_DOWN_ERROR: &str = "__tau_server_shutting_down__";
pub fn is_shutting_down_error(err: &str) -> bool {
err == SHUTTING_DOWN_ERROR || err.contains("server is shutting down")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub model: String,
pub provider: String,
pub cwd: Option<String>,
pub message_count: usize,
pub stats: SessionStats,
pub last_activity: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default)]
pub child_count: usize,
#[serde(default)]
pub child_budget: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub tagline: Option<String>,
#[serde(default = "default_state")]
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_pct: Option<f64>,
#[serde(default)]
pub archived: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_exit_status: Option<String>,
#[serde(default)]
pub is_live: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_started_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase_started_at_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionResult {
pub session_id: String,
pub status: String,
pub summary: String,
}
fn default_wait_timeout() -> u64 {
300
}
fn default_true() -> bool {
true
}
fn default_state() -> String {
"idle".into()
}
fn default_recent_limit() -> usize {
10
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub provider: String,
pub thinking: crate::types::ThinkingStyle,
pub context_window: u64,
pub max_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AliasInfo {
pub name: String,
pub target: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskInfo {
pub id: i64,
pub project_name: String,
pub title: String,
pub state: String,
pub priority: i64,
pub parent_id: Option<i64>,
pub tags: Option<serde_json::Value>,
pub affected_files: Option<serde_json::Value>,
pub branch: Option<String>,
pub worktree_path: Option<String>,
pub session_id: Option<String>,
pub skip_review: bool,
pub require_approval: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_profile: Option<String>,
#[serde(default)]
pub held: bool,
#[serde(default)]
pub has_live_session: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filed_by_project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filed_by_session_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskMessageInfo {
pub id: i64,
pub task_id: i64,
pub content: String,
pub author: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskRelationInfo {
pub from_task: i64,
pub to_task: i64,
pub relation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSessionInfo {
pub session_id: String,
pub role: String,
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_phase: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_exit_status: Option<String>,
#[serde(default)]
pub is_live: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskWaitReasons {
pub task_id: i64,
pub reasons: Vec<TaskWaitReason>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TaskWaitReason {
Dependency {
task_id: i64,
title: String,
state: String,
project_name: String,
},
FileConflict {
files: Vec<String>,
with_task_id: i64,
},
BudgetExhausted { used: usize, max: usize },
MergeTargetNotFound { branch: String },
NotScheduled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskHistoryInfo {
pub field: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub created_at: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionStats {
pub user_messages: usize,
pub assistant_messages: usize,
pub tool_calls: usize,
pub tool_results: usize,
pub tokens: TokenStats,
pub cost: f64,
pub is_subscription: bool,
pub context_window: u64,
pub context_tokens: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenStats {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_write: u64,
}
impl TokenStats {
pub fn total(&self) -> u64 {
self.input + self.output + self.cache_read + self.cache_write
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectStatsInfo {
pub project_name: String,
pub session_count: usize,
pub message_count: usize,
pub tokens_input: u64,
pub tokens_output: u64,
pub tokens_cache_read: u64,
pub tokens_cache_write: u64,
pub cost_usd: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_activity: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectInfoEntry {
pub name: String,
pub path: String,
}
pub fn format_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
#[allow(clippy::cast_precision_loss)]
pub fn format_stats(stats: &SessionStats) -> String {
let mut parts = Vec::new();
if stats.tokens.input > 0 {
parts.push(format!("↑{}", format_tokens(stats.tokens.input)));
}
if stats.tokens.output > 0 {
parts.push(format!("↓{}", format_tokens(stats.tokens.output)));
}
if stats.tokens.cache_read > 0 {
parts.push(format!("R{}", format_tokens(stats.tokens.cache_read)));
}
if stats.tokens.cache_write > 0 {
parts.push(format!("W{}", format_tokens(stats.tokens.cache_write)));
}
let cost_str = if stats.is_subscription {
format!("${:.3} (sub)", stats.cost)
} else if stats.cost > 0.0 {
format!("${:.3}", stats.cost)
} else {
String::new()
};
if !cost_str.is_empty() {
parts.push(cost_str);
}
if stats.context_window > 0 {
let ctx = match stats.context_tokens {
Some(t) => {
let pct = (t as f64 / stats.context_window as f64) * 100.0;
format!("{:.1}%/{}", pct, format_tokens(stats.context_window))
}
None => format!("?/{}", format_tokens(stats.context_window)),
};
parts.push(ctx);
}
parts.join(" ")
}
fn format_resets_at(resets_at: &str) -> String {
let trimmed = resets_at.trim().trim_end_matches('Z');
let (date_part, time_part) = match trimmed.split_once('T') {
Some(pair) => pair,
None => return "?".into(),
};
let mut date_iter = date_part.split('-');
let year: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let month: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let day: i64 = date_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let time_clean = time_part
.split('+')
.next()
.unwrap_or(time_part)
.split('.')
.next()
.unwrap_or(time_part);
let mut time_iter = time_clean.split(':');
let hour: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let minute: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let second: i64 = time_iter.next().and_then(|s| s.parse().ok()).unwrap_or(0);
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y.rem_euclid(400);
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
let reset_epoch =
days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let delta = reset_epoch - now;
if delta <= 0 {
return "?".into();
}
format_duration_compact(delta)
}
fn format_duration_compact(secs: i64) -> String {
if secs >= 86400 {
format!("{}d", secs / 86400)
} else if secs >= 3600 {
format!("{}h", secs / 3600)
} else if secs >= 60 {
format!("{}m", secs / 60)
} else {
format!("{}s", secs)
}
}
pub fn format_utilization(utilization: Option<f64>) -> String {
match utilization {
Some(u) => format!("{:.0}%", u),
None => "?".into(),
}
}
fn format_usage_bucket(label: &str, bucket: &UsageBucket) -> Option<String> {
let pct = format_utilization(bucket.utilization);
if pct == "?" {
return None;
}
let reset = bucket
.resets_at
.as_deref()
.map(format_resets_at)
.unwrap_or_else(|| "?".into());
Some(format!("{} {} {}", label, pct, reset))
}
pub fn format_subscription_usage(usage: &SubscriptionUsage) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(ref b) = usage.five_hour
&& let Some(s) = format_usage_bucket("5h", b)
{
parts.push(s);
}
if let Some(ref b) = usage.seven_day
&& let Some(s) = format_usage_bucket("7d", b)
{
parts.push(s);
}
if let Some(ref b) = usage.seven_day_sonnet
&& let Some(s) = format_usage_bucket("sonnet", b)
{
parts.push(s);
}
if let Some(ref b) = usage.seven_day_opus
&& let Some(s) = format_usage_bucket("opus", b)
{
parts.push(s);
}
if parts.is_empty() {
None
} else {
Some(format!("({})", parts.join(" | ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_tokens_units() {
assert_eq!(format_tokens(0), "0");
assert_eq!(format_tokens(999), "999");
assert_eq!(format_tokens(1_000), "1.0K");
assert_eq!(format_tokens(12_345), "12.3K");
assert_eq!(format_tokens(999_999), "1000.0K");
assert_eq!(format_tokens(1_000_000), "1.0M");
assert_eq!(format_tokens(18_500_000), "18.5M");
}
#[test]
fn format_stats_empty() {
let stats = SessionStats::default();
assert_eq!(format_stats(&stats), "");
}
#[test]
fn format_stats_basic() {
let stats = SessionStats {
tokens: TokenStats {
input: 12_000,
output: 81_000,
cache_read: 18_000_000,
cache_write: 353_000,
},
cost: 13.434,
is_subscription: true,
context_window: 200_000,
context_tokens: Some(36_800),
..Default::default()
};
let s = format_stats(&stats);
assert!(s.contains("↑12.0K"), "got: {s}");
assert!(s.contains("↓81.0K"), "got: {s}");
assert!(s.contains("R18.0M"), "got: {s}");
assert!(s.contains("W353.0K"), "got: {s}");
assert!(s.contains("$13.434 (sub)"), "got: {s}");
assert!(s.contains("18.4%/200.0K"), "got: {s}");
}
#[test]
fn format_stats_unknown_context() {
let stats = SessionStats {
context_window: 200_000,
context_tokens: None,
..Default::default()
};
let s = format_stats(&stats);
assert!(s.contains("?/200.0K"), "got: {s}");
}
#[test]
fn format_stats_no_subscription() {
let stats = SessionStats {
tokens: TokenStats {
input: 500,
output: 200,
..Default::default()
},
cost: 0.005,
is_subscription: false,
..Default::default()
};
let s = format_stats(&stats);
assert!(s.contains("$0.005"), "got: {s}");
assert!(!s.contains("(sub)"), "got: {s}");
}
#[test]
fn format_subscription_usage_basic() {
use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
let usage = SubscriptionUsage {
five_hour: Some(UsageBucket {
utilization: Some(50.0),
resets_at: Some("2099-01-01T16:00:00Z".into()),
}),
seven_day: Some(UsageBucket {
utilization: Some(12.0),
resets_at: Some("2099-01-03T00:00:00Z".into()),
}),
seven_day_sonnet: Some(UsageBucket {
utilization: Some(6.0),
resets_at: Some("2099-01-02T00:00:00Z".into()),
}),
seven_day_opus: None,
extra_usage: None,
};
let s = format_subscription_usage(&usage).unwrap();
assert!(s.starts_with('('), "got: {s}");
assert!(s.ends_with(')'), "got: {s}");
assert!(s.contains("5h 50%"), "got: {s}");
assert!(s.contains("7d 12%"), "got: {s}");
assert!(s.contains("sonnet 6%"), "got: {s}");
assert!(s.contains(" | "), "got: {s}");
}
#[test]
fn format_subscription_usage_empty() {
use crate::subscription_usage::SubscriptionUsage;
let usage = SubscriptionUsage::default();
assert!(format_subscription_usage(&usage).is_none());
}
#[test]
fn format_subscription_usage_no_utilization() {
use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
let usage = SubscriptionUsage {
five_hour: Some(UsageBucket {
utilization: None,
resets_at: Some("2099-01-01T16:00:00Z".into()),
}),
..Default::default()
};
assert!(format_subscription_usage(&usage).is_none());
}
#[test]
fn format_duration_compact_units() {
assert_eq!(format_duration_compact(30), "30s");
assert_eq!(format_duration_compact(90), "1m");
assert_eq!(format_duration_compact(3600), "1h");
assert_eq!(format_duration_compact(7200), "2h");
assert_eq!(format_duration_compact(86400), "1d");
assert_eq!(format_duration_compact(172800), "2d");
}
#[test]
fn task_protocol_serde_roundtrip() {
let task = TaskInfo {
id: 42,
project_name: "test-project".into(),
title: "test task".into(),
state: "active".into(),
priority: 5,
parent_id: Some(1),
tags: Some(serde_json::json!(["foo", "bar"])),
affected_files: None,
branch: Some("task-42".into()),
worktree_path: None,
session_id: Some("s123".into()),
skip_review: false,
require_approval: false,
sandbox_profile: None,
held: false,
has_live_session: false,
filed_by_project: None,
filed_by_session_id: None,
created_at: 1000,
updated_at: 2000,
};
let msg = TaskMessageInfo {
id: 1,
task_id: 42,
content: "hello".into(),
author: Some("test".into()),
created_at: 1000,
updated_at: 2000,
};
let rel = TaskRelationInfo {
from_task: 42,
to_task: 43,
relation: "depends_on".into(),
};
let requests: Vec<Request> = vec![
Request::SetTagline {
session_id: "s1".into(),
tagline: "hi".into(),
},
Request::TaskList {
project: "/tmp".into(),
state: Some("active".into()),
parent_id: None,
},
Request::TaskGet { id: 42 },
Request::TaskCreate {
project: "/tmp".into(),
title: "new".into(),
parent_id: None,
priority: Some(3),
tags: vec!["a".into()],
sandbox_profile: None,
},
Request::TaskUpdate {
id: 42,
state: Some("approved".into()),
title: None,
priority: None,
tags: None,
affected_files: None,
skip_review: None,
require_approval: None,
sandbox_profile: None,
},
Request::TaskSearch {
project: "/tmp".into(),
query: "test".into(),
state: None,
},
Request::TaskAssign {
id: 42,
session_id: "s1".into(),
},
Request::TaskStatus {
project: "/tmp".into(),
},
Request::TaskOverview {
project: "/tmp".into(),
recent_limit: 5,
},
Request::TaskMergeQueue {
project: "/tmp".into(),
},
Request::ProjectStats {
project_name: "tau".into(),
},
Request::GetProjectInfo {
project_name: "tau".into(),
},
];
for req in &requests {
let json = serde_json::to_string(req).expect("serialize request");
let _: Request = serde_json::from_str(&json).expect("deserialize request");
}
let responses: Vec<Response> = vec![
Response::TaskList {
tasks: vec![task.clone()],
},
Response::TaskDetail {
task: task.clone(),
messages: vec![msg],
relations: vec![rel],
subtasks: vec![task.clone()],
sessions: Vec::new(),
history: Vec::new(),
},
Response::TaskUpdated { task: task.clone() },
Response::TaskStatus {
text: "status text".into(),
},
Response::TaskOverview {
active: vec![task.clone()],
queued_ready: Vec::new(),
queued_planning: Vec::new(),
blocked: Vec::new(),
held: Vec::new(),
recently_merged: Vec::new(),
recently_closed: Vec::new(),
inflight_count: 1,
max_concurrent: 8,
wait_reasons: vec![TaskWaitReasons {
task_id: 99,
reasons: vec![
TaskWaitReason::Dependency {
task_id: 42,
title: "dep".into(),
state: "active".into(),
project_name: "tau".into(),
},
TaskWaitReason::BudgetExhausted { used: 8, max: 8 },
],
}],
},
Response::TaskTree {
tasks: vec![(0, task.clone())],
},
Response::TaskMergeQueue { tasks: vec![task] },
Response::ProjectStats {
stats: ProjectStatsInfo {
project_name: "tau".into(),
session_count: 42,
message_count: 8124,
tokens_input: 12_340_156,
tokens_output: 418_902,
tokens_cache_read: 34_521_088,
tokens_cache_write: 2_108_445,
cost_usd: 28.47,
last_activity: Some(1_700_000_000),
},
},
Response::ProjectInfo {
project: Some(ProjectInfoEntry {
name: "tau".into(),
path: "/home/u/src/tau".into(),
}),
},
Response::ProjectInfo { project: None },
];
for resp in &responses {
let json = serde_json::to_string(resp).expect("serialize response");
let _: Response = serde_json::from_str(&json).expect("deserialize response");
}
}
#[test]
fn shutting_down_error_round_trips_through_response() {
let err = Response::Error {
message: SHUTTING_DOWN_ERROR.into(),
};
let wire = serde_json::to_string(&err).expect("serialize");
let parsed: Response = serde_json::from_str(&wire).expect("deserialize");
match parsed {
Response::Error { message } => {
assert!(is_shutting_down_error(&message));
}
other => panic!("unexpected variant: {:?}", other),
}
assert!(is_shutting_down_error(SHUTTING_DOWN_ERROR));
assert!(is_shutting_down_error("server is shutting down"));
assert!(!is_shutting_down_error("some other error"));
}
#[test]
fn chat_serialises_without_attachments_when_empty() {
let req = Request::Chat {
session_id: "s1".into(),
text: "hi".into(),
attachments: Vec::new(),
};
let json = serde_json::to_string(&req).expect("serialize");
assert!(
!json.contains("attachments"),
"empty attachments should be omitted from JSON, got: {json}"
);
let parsed: Request = serde_json::from_str(&json).expect("deserialize");
match parsed {
Request::Chat {
session_id,
text,
attachments,
} => {
assert_eq!(session_id, "s1");
assert_eq!(text, "hi");
assert!(attachments.is_empty());
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn chat_with_image_roundtrips() {
let req = Request::Chat {
session_id: "s1".into(),
text: "describe".into(),
attachments: vec![ChatAttachment::Image {
data: "AAAA".into(),
mime_type: "image/png".into(),
}],
};
let json = serde_json::to_string(&req).expect("serialize");
assert!(json.contains("\"attachments\""), "got: {json}");
assert!(json.contains("\"type\":\"image\""), "got: {json}");
assert!(json.contains("\"mime_type\":\"image/png\""), "got: {json}");
let parsed: Request = serde_json::from_str(&json).expect("deserialize");
match parsed {
Request::Chat {
session_id,
text,
attachments,
} => {
assert_eq!(session_id, "s1");
assert_eq!(text, "describe");
assert_eq!(attachments.len(), 1);
match &attachments[0] {
ChatAttachment::Image { data, mime_type } => {
assert_eq!(data, "AAAA");
assert_eq!(mime_type, "image/png");
}
}
}
other => panic!("unexpected variant: {:?}", other),
}
}
#[test]
fn legacy_chat_payload_deserialises() {
let json = r#"{"type":"chat","session_id":"s","text":"hi"}"#;
let parsed: Request = serde_json::from_str(json).expect("deserialize legacy");
match parsed {
Request::Chat {
session_id,
text,
attachments,
} => {
assert_eq!(session_id, "s");
assert_eq!(text, "hi");
assert!(attachments.is_empty());
}
other => panic!("unexpected variant: {:?}", other),
}
}
}