Skip to main content

tau_agent_base/
protocol.rs

1//! JSON-lines wire protocol over unix domain socket.
2
3use serde::{Deserialize, Serialize};
4
5use crate::subscription_usage::{SubscriptionUsage, UsageBucket};
6use crate::types::StreamEvent;
7
8// ---------------------------------------------------------------------------
9// Client → Server
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum Request {
15    /// Send a chat message in a session.
16    Chat {
17        session_id: String,
18        text: String,
19        /// Optional attachments (images for now). Empty by default for
20        /// backward compatibility with older clients/payloads.
21        #[serde(default, skip_serializing_if = "Vec::is_empty")]
22        attachments: Vec<ChatAttachment>,
23    },
24    /// Create a new session.
25    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        /// Working directory for tool execution.
33        #[serde(skip_serializing_if = "Option::is_none")]
34        cwd: Option<String>,
35        /// Parent session ID (for child sessions).
36        #[serde(skip_serializing_if = "Option::is_none")]
37        parent_id: Option<String>,
38        /// Max descendant sessions this session can spawn.
39        #[serde(default)]
40        child_budget: u32,
41        /// Short description of the session's task.
42        #[serde(skip_serializing_if = "Option::is_none")]
43        tagline: Option<String>,
44        /// When true, auto-archive this session after completion+join.
45        #[serde(default)]
46        auto_archive: bool,
47        /// When true, notify parent session on child completion (default true).
48        #[serde(default = "default_true")]
49        notify_parent: bool,
50        /// Project name (from discover_project or explicit).
51        #[serde(skip_serializing_if = "Option::is_none")]
52        project_name: Option<String>,
53        /// Sandbox profile name (from task config) for plugin spawning.
54        #[serde(skip_serializing_if = "Option::is_none")]
55        sandbox_profile: Option<String>,
56    },
57    /// Get info about a specific session.
58    GetSessionInfo { session_id: String },
59    /// Return the requested session and all its ancestors.
60    ///
61    /// Ordered leaf-first: index 0 is `session_id`, the last entry is the root
62    /// (or the deepest reachable ancestor when the depth guard trips or a
63    /// `parent_id` points at a missing row).
64    ///
65    /// Returns an empty `sessions` vec if `session_id` itself is unknown —
66    /// **not** an error response.
67    GetSessionAncestors { session_id: String },
68    /// List sessions.
69    ListSessions {
70        /// Include archived sessions in the listing.
71        #[serde(default)]
72        include_archived: bool,
73        /// If set, only list sessions belonging to this project.
74        #[serde(skip_serializing_if = "Option::is_none")]
75        project_name: Option<String>,
76    },
77    /// Archive a session (and all its children).
78    ArchiveSession {
79        session_id: String,
80        /// If set, the server verifies that `session_id` is a descendant of
81        /// this ancestor before archiving.  The TUI sends `None` (no
82        /// restriction); orchestration tools send `Some(current_session_id)`.
83        #[serde(default)]
84        require_ancestor: Option<String>,
85    },
86    /// Restore (un-archive) a session and all its descendants.
87    RestoreSession { session_id: String },
88    /// Delete a session.
89    DeleteSession { session_id: String },
90    /// List available models.
91    ListModels,
92    /// List configured aliases (global + per-project).
93    ///
94    /// `cwd` is the project directory whose `.tau/models.toml` should be
95    /// inspected for project-level aliases.  Pass `None` to get global
96    /// aliases only.
97    ///
98    /// Added in protocol v0.2: older servers will respond with an error.
99    /// Clients should treat that as "no aliases" and degrade gracefully.
100    ListAliases {
101        #[serde(default)]
102        cwd: Option<String>,
103    },
104    /// Change model for a session.
105    SetModel {
106        session_id: String,
107        model_id: String,
108        /// Session id of the caller when invoked via an orchestration tool
109        /// (used to attribute the change in the session's info-message log).
110        /// `None` when invoked by the TUI/CLI/external API.
111        #[serde(default, skip_serializing_if = "Option::is_none")]
112        caller_session_id: Option<String>,
113    },
114    /// Change working directory for a session.
115    SetCwd {
116        session_id: String,
117        cwd: String,
118        /// Session id of the caller when invoked via an orchestration tool.
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        caller_session_id: Option<String>,
121    },
122    /// Re-parent all child sessions from one parent to another.
123    ReparentChildren {
124        old_parent_id: String,
125        new_parent_id: String,
126    },
127    /// Start OAuth login for a provider.
128    Login { provider: String },
129    /// Query authentication status.
130    AuthStatus,
131    /// Fetch subscription usage (OAuth only, cached 5 min).
132    GetSubscriptionUsage,
133    /// Get message history for a session.
134    GetMessages { session_id: String },
135    /// Subscribe to live events on a session (for multi-client).
136    /// The connection stays open and receives Stream/AgentDone/Cancelled events.
137    Subscribe { session_id: String },
138    /// Wait for sessions to complete.
139    WaitSessions {
140        session_ids: Vec<String>,
141        #[serde(default = "default_wait_timeout")]
142        timeout_secs: u64,
143    },
144    /// Wait for any of the specified sessions to complete (returns as soon as >= 1 is done).
145    WaitAnySessions {
146        session_ids: Vec<String>,
147        #[serde(default = "default_wait_timeout")]
148        timeout_secs: u64,
149    },
150    /// Cancel an in-progress chat (agent loop) for a session.
151    CancelChat {
152        session_id: String,
153        /// Session id of the caller when invoked via an orchestration tool
154        /// (e.g. `session_cancel` from another session). `None` when invoked
155        /// via the TUI/CLI/external API.
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        caller_session_id: Option<String>,
158    },
159    /// Inject a steering message into a running agent loop.
160    /// The message is inserted as a user message between tool results
161    /// and the next LLM call. If no agent is running, treated as Chat.
162    Steer { session_id: String, text: String },
163    /// Trigger context compaction now. Optional `keep_hint` is free-form
164    /// text the summarizer is asked to preserve in addition to its standard
165    /// sections (advisory, not a hard filter).
166    Compact {
167        session_id: String,
168        #[serde(default, skip_serializing_if = "Option::is_none")]
169        keep_hint: Option<String>,
170    },
171
172    /// Queue a message for delivery to a target session.
173    /// When `await_reply` is true the caller blocks until the target
174    /// calls `session_reply` with the corresponding `msg_id`.
175    QueueMessage {
176        target_session_id: String,
177        content: String,
178        sender_info: String,
179        /// When true, block until the target replies.
180        #[serde(default)]
181        await_reply: bool,
182        /// For threaded replies: the msg_id this message is responding to.
183        #[serde(skip_serializing_if = "Option::is_none")]
184        reply_to: Option<String>,
185    },
186    /// Persist a zero-token display-only info message to a session's
187    /// message history. Unlike `QueueMessage`, this does **not** wake the
188    /// agent loop and the message is excluded from LLM context.
189    ///
190    /// Intended for observational notifications such as task state-change
191    /// info-lines surfaced in the TUI.
192    QueueInfo {
193        target_session_id: String,
194        text: String,
195    },
196    /// Reply to a pending `await_reply` message.
197    ReplyToMessage { msg_id: String, content: String },
198    /// Reload plugins for a session (destroy + re-init).
199    ReloadPlugins { session_id: String },
200    /// Re-read `providers.toml` and global `models.toml` without restarting
201    /// the server. On success, the in-memory provider/model tables and the
202    /// global alias map are swapped in; on error (IO / parse failure) the
203    /// existing state is left untouched and the server returns
204    /// [`Response::Error`] so a broken edit can't brick a running server.
205    ///
206    /// Narrow by design: this does **not** reload plugins (see
207    /// [`Request::ReloadPlugins`]), `auth.json` (re-read per request),
208    /// or per-project `.tau/models.toml` (re-read per lookup).
209    ReloadConfig,
210    /// Garbage-collect archived sessions older than a threshold.
211    GcSessions {
212        /// Delete archived sessions older than this many days.
213        older_than_days: u64,
214    },
215    /// Broadcast a hook to other plugins (plugin-to-plugin communication).
216    FireHook {
217        name: String,
218        data: serde_json::Value,
219    },
220    /// Execute a tool directly on a session (no LLM involved).
221    ExecuteTool {
222        session_id: String,
223        tool_name: String,
224        arguments: serde_json::Value,
225    },
226    /// Enqueue a Tier-3 post-idle action for the given session. The server
227    /// drains the queue once the session's lock releases (after the agent
228    /// loop exits). Intended for side effects that need exclusive access
229    /// to the caller's session or its subtree (e.g. archival, merge pass).
230    ///
231    /// See [`crate::types::PostIdleAction`] for the action semantics.
232    EnqueuePostIdleAction {
233        session_id: String,
234        action: crate::types::PostIdleAction,
235    },
236    /// Set the tagline for a session.
237    SetTagline { session_id: String, tagline: String },
238    /// List tasks for a project.
239    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    /// Get full details of a task.
247    TaskGet { id: i64 },
248    /// Create a new task.
249    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    /// Update a task (state, title, priority, etc.).
262    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    /// Search tasks by query.
282    TaskSearch {
283        project: String,
284        query: String,
285        #[serde(skip_serializing_if = "Option::is_none")]
286        state: Option<String>,
287    },
288    /// Assign a task to a session.
289    TaskAssign { id: i64, session_id: String },
290    /// Get scheduler status.
291    TaskStatus { project: String },
292    /// Structured task overview for interactive rendering.
293    ///
294    /// Returns active/queued/blocked/held tasks plus a bounded tail of
295    /// recently-terminated (`merged` / `closed`) tasks, all as `TaskInfo`
296    /// rather than pre-formatted text.  Consumers (the TUI task picker)
297    /// render the overview grouped by scheduler position.
298    ///
299    /// `recent_limit` applies **per bucket** — up to `recent_limit` merged
300    /// tasks **plus** up to `recent_limit` closed tasks, so the tail length
301    /// is at most `2 * recent_limit`.
302    TaskOverview {
303        project: String,
304        /// Max number of recently-terminated tasks to include *per bucket*
305        /// (merged and closed are capped separately).  Defaults to 10.
306        #[serde(default = "default_recent_limit")]
307        recent_limit: usize,
308    },
309    /// Get merge queue (approved + merging tasks).
310    TaskMergeQueue { project: String },
311    /// Project-wide aggregate usage / cost stats.
312    ///
313    /// Returns totals across every session (archived included) belonging
314    /// to `project_name`.
315    ProjectStats { project_name: String },
316    /// Look up a project by name. Returns the project's root path so the
317    /// caller can recover when a session's `cwd` has disappeared
318    /// (worktree removed, etc.) and the worker wants to fall back to the
319    /// project root before executing a bash command. See task 720.
320    GetProjectInfo { project_name: String },
321    /// Shut down the server.
322    Shutdown {
323        /// If true, server is restarting (clients should reconnect).
324        #[serde(default)]
325        restart: bool,
326    },
327}
328
329/// Attachments to a `Request::Chat` message.
330///
331/// Today only images are supported; the structure is an open enum so we can
332/// add more attachment kinds without bumping the protocol shape.
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[serde(tag = "type", rename_all = "snake_case")]
335pub enum ChatAttachment {
336    /// An image. `data` is base64-encoded image bytes; `mime_type` is one
337    /// of the MIME types accepted by the validator (`image/png`, `image/jpeg`,
338    /// `image/gif`, `image/webp`). The server validates both fields before
339    /// building the `UserMessage` and rejects oversized or malformed payloads
340    /// with a `Response::Error` rather than panicking.
341    Image { data: String, mime_type: String },
342}
343
344impl ChatAttachment {
345    /// Convert this attachment into an engine `UserContent` block.
346    ///
347    /// Pure structural mapping; callers that need validation (decoded byte
348    /// length, allowed MIME, etc.) should run that *before* calling this.
349    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// ---------------------------------------------------------------------------
362// Server → Client
363// ---------------------------------------------------------------------------
364
365#[derive(Debug, Serialize, Deserialize, Clone)]
366#[serde(tag = "type", rename_all = "snake_case")]
367pub enum Response {
368    /// Session was created.
369    SessionCreated { session_id: String },
370    /// Info about a single session.
371    SessionInfo { info: SessionInfo },
372    /// Ancestor chain for a session, leaf-first.  See `Request::GetSessionAncestors`.
373    SessionAncestors { sessions: Vec<SessionInfo> },
374    /// List of sessions.
375    Sessions { sessions: Vec<SessionInfo> },
376    /// Session deleted.
377    SessionDeleted,
378    /// Session archived.
379    SessionArchived,
380    /// Session restored (un-archived).
381    SessionRestored,
382    /// Available models.
383    Models { models: Vec<ModelInfo> },
384    /// Configured aliases (global + per-project).
385    ///
386    /// Added in protocol v0.2.  Older clients will not understand this
387    /// variant and will fall through to their default error path.
388    Aliases {
389        #[serde(default)]
390        global: Vec<AliasInfo>,
391        #[serde(default)]
392        project: Vec<AliasInfo>,
393    },
394    /// Model changed.
395    ModelChanged { model: ModelInfo },
396    /// Streaming event from the LLM.
397    Stream { event: Box<StreamEvent> },
398    /// OAuth login succeeded.
399    LoginSuccess { provider: String },
400    /// Authentication status.
401    AuthStatus { providers: Vec<String> },
402    /// Subscription usage data.
403    SubscriptionUsage { usage: SubscriptionUsage },
404    /// Server is shutting down. Clients should reconnect if restart=true.
405    ServerShutdown { restart: bool },
406    /// Sessions completed (response to WaitSessions).
407    SessionsCompleted { results: Vec<SessionResult> },
408    /// Agent loop was cancelled by the user.
409    Cancelled,
410    /// Message history for a session.
411    Messages {
412        messages: Vec<crate::types::Message>,
413    },
414    /// A user message was sent (broadcast to subscribers).
415    UserMessage { text: String },
416    /// Agent loop completed (all turns done).
417    AgentDone,
418    /// Reply content (returned to a QueueMessage with await_reply=true).
419    MessageReply { content: String },
420    /// Success (generic ack).
421    Ok,
422    /// Success, with an advisory note from the server.
423    ///
424    /// Emitted in place of `Ok` when the server wants to tell the caller
425    /// something about how the request was handled without treating it as
426    /// an error. Today: `QueueMessage` (fire-and-forget) targeting a
427    /// placeholder (log-provider) session — the note explains that the
428    /// message was recorded but no agent loop ran. See task 582.
429    ///
430    /// Older clients that don't know this variant will fall through to
431    /// their default-error path; the request still succeeded on the
432    /// server side.
433    OkWithNote { note: String },
434    /// Garbage-collection result.
435    GcComplete { deleted: usize },
436    /// Tool execution result (response to ExecuteTool).
437    ToolExecuted { content: String, is_error: bool },
438    /// List of tasks (flat, for search/merge queue results).
439    TaskList { tasks: Vec<TaskInfo> },
440    /// Full task details (response to TaskGet).
441    TaskDetail {
442        task: TaskInfo,
443        messages: Vec<TaskMessageInfo>,
444        relations: Vec<TaskRelationInfo>,
445        subtasks: Vec<TaskInfo>,
446        /// Every `(session_id, role)` pair recorded for this task, enriched
447        /// with best-effort session state.  Missing / deleted / cross-project
448        /// sessions are dropped silently.  Back-compat: older clients that
449        /// don't know about this field ignore it.
450        #[serde(default, skip_serializing_if = "Vec::is_empty")]
451        sessions: Vec<TaskSessionInfo>,
452        /// State transitions and other task updates in chronological order.
453        #[serde(default, skip_serializing_if = "Vec::is_empty")]
454        history: Vec<TaskHistoryInfo>,
455    },
456    /// Task created or updated (response to TaskCreate, TaskUpdate, TaskAssign).
457    TaskUpdated { task: TaskInfo },
458    /// Scheduler status text (response to TaskStatus).
459    TaskStatus { text: String },
460    /// Structured scheduler overview (response to TaskOverview).
461    TaskOverview {
462        /// Tasks in active lifecycle states (active, review, merging, refining).
463        active: Vec<TaskInfo>,
464        /// Tasks ready to dispatch (state=ready, not held, deps satisfied).
465        queued_ready: Vec<TaskInfo>,
466        /// Tasks queued for planning (state=planning, deps satisfied).
467        queued_planning: Vec<TaskInfo>,
468        /// Tasks blocked by unmet dependencies (state=ready or planning).
469        blocked: Vec<TaskInfo>,
470        /// Tasks held (state=ready or planning, held=true).
471        held: Vec<TaskInfo>,
472        /// Most recently merged tasks, newest first, capped at `recent_limit`
473        /// (the request's per-bucket limit).
474        recently_merged: Vec<TaskInfo>,
475        /// Most recently closed tasks, newest first, capped at `recent_limit`
476        /// (the request's per-bucket limit; merged and closed are independent).
477        recently_closed: Vec<TaskInfo>,
478        /// Current in-flight count (active/review/merging/refining).
479        inflight_count: usize,
480        /// Scheduler's max-concurrent budget.
481        max_concurrent: usize,
482        /// For each queued/blocked task id, the full list of wait reasons
483        /// keeping it from dispatch. Dependency reasons drive the inline
484        /// `⏳ #N` suffix in the picker; the detail pane renders every
485        /// reason verbatim.
486        #[serde(default, skip_serializing_if = "Vec::is_empty")]
487        wait_reasons: Vec<TaskWaitReasons>,
488    },
489    /// Task list with tree depth info (response to TaskList).
490    TaskTree { tasks: Vec<(usize, TaskInfo)> },
491    /// Merge queue (approved + merging tasks, response to TaskMergeQueue).
492    TaskMergeQueue { tasks: Vec<TaskInfo> },
493    /// Project-wide usage / cost totals (response to `ProjectStats`).
494    ProjectStats { stats: ProjectStatsInfo },
495    /// Project metadata (response to `GetProjectInfo`).
496    ///
497    /// `project` is `None` when the named project does not exist; this is
498    /// not treated as an error response so callers can match on "unknown
499    /// project" cleanly.
500    ProjectInfo { project: Option<ProjectInfoEntry> },
501    /// Error.
502    Error { message: String },
503}
504
505/// Sentinel message used by `Response::Error { message }` when a request
506/// was refused because the server is in the shutdown-drain window.
507///
508/// Clients recognise this exact string to distinguish "server is
509/// transitioning" (reconnect + retry) from "this specific operation
510/// failed" (surface the error). Kept as a plain constant rather than a
511/// dedicated enum variant so that older clients that only know about
512/// `Response::Error` still see a human-readable message in the UI.
513pub const SHUTTING_DOWN_ERROR: &str = "__tau_server_shutting_down__";
514
515/// Returns true if `err` is the distinctive "server is shutting down"
516/// signal produced by the server during its drain window. Used by
517/// clients to trigger reconnect/retry paths instead of surfacing the
518/// error to the user.
519pub 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    /// Unix timestamp (seconds) of last activity (last message or session creation).
532    pub last_activity: i64,
533    /// Parent session ID (None for root sessions).
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub parent_id: Option<String>,
536    /// Number of direct child sessions.
537    #[serde(default)]
538    pub child_count: usize,
539    /// Budget for descendant sessions.
540    #[serde(default)]
541    pub child_budget: u32,
542    /// Short description of what this session is working on.
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub tagline: Option<String>,
545    /// Current agent phase: "idle", "thinking", "responding", "tool_exec", etc.
546    #[serde(default = "default_state")]
547    pub state: String,
548    /// Context usage as percentage (0-100), if known.
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub context_pct: Option<f64>,
551    /// Whether this session is archived.
552    #[serde(default)]
553    pub archived: bool,
554    /// Project name this session belongs to.
555    #[serde(skip_serializing_if = "Option::is_none")]
556    pub project_name: Option<String>,
557    /// Last exit status: null (never ran), "completed", "error", "cancelled", "max_turns".
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub last_exit_status: Option<String>,
560    /// True when a chat turn is actively running for this session right now.
561    /// False means the session is idle — `state` may reflect a stale phase
562    /// from a previous turn or server restart.
563    #[serde(default)]
564    pub is_live: bool,
565    /// Unix-ms timestamp when the current non-Idle turn began on the
566    /// server. `Some(_)` while a turn is in flight, `None` when the
567    /// session is idle. Used by the TUI to anchor the "Working... Xs"
568    /// counter so it remains correct when attaching to an already-running
569    /// session from the picker.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub turn_started_at_ms: Option<u64>,
572    /// Unix-ms timestamp when the current phase began on the server.
573    /// Re-stamped on every phase transition within a turn; `None` when
574    /// the session is idle. Symmetric to `turn_started_at_ms` so
575    /// late-subscribing clients can anchor the per-phase elapsed counter.
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub phase_started_at_ms: Option<u64>,
578}
579
580/// Result for a single session in WaitSessions response.
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct SessionResult {
583    pub session_id: String,
584    /// "done", "error", "cancelled", "timeout"
585    pub status: String,
586    /// Last assistant message text (truncated).
587    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/// One configured alias entry: a short name pointing at a target.
617///
618/// Targets are model ids, optionally prefixed with `provider/`.  See
619/// [`crate::model_resolve`] for resolution rules.
620#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct AliasInfo {
622    /// The short name users type, e.g. `"smart"`.
623    pub name: String,
624    /// What the alias points at, e.g. `"claude-opus-4-6"` or
625    /// `"openai/gpt-4.1-mini"`.
626    pub target: String,
627}
628
629/// Task info for wire protocol (mirrors tasks_db::Task but protocol-owned).
630#[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    /// Best-effort hint: true when any session recorded on this task is
650    /// currently running a chat turn.  Populated server-side for the
651    /// TaskList / TaskTree / TaskDetail responses; defaults to false for
652    /// back-compat with older clients / serialised payloads.
653    #[serde(default)]
654    pub has_live_session: bool,
655    /// Project the task was *filed from* — the calling session's
656    /// project at `task_create` time. Distinct from
657    /// [`TaskInfo::project_name`], which is where the work runs. Equal
658    /// for same-project filing, different for cross-project filing
659    /// (#750). `None` for tasks created before #758. See
660    /// `tasks_db::Task::filed_by_project` for full semantics.
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub filed_by_project: Option<String>,
663    /// Session id of the caller that ran `task_create`. `None` for
664    /// tasks created before #758, or when no calling session was
665    /// available.
666    #[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/// Task message info for wire protocol.
673#[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/// Task relation info for wire protocol.
684#[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/// Session recorded against a task, enriched with best-effort live state.
692///
693/// One `TaskSessionInfo` per `(task_id, session_id, role)` row in
694/// `task_sessions`.  Enrichment fields are `Option<T>` because a session may
695/// have been deleted, archived to a different store, or be otherwise
696/// unreadable — we still want to surface the bare `(session_id, role)` row.
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct TaskSessionInfo {
699    /// Session ID.
700    pub session_id: String,
701    /// Role: "creator" | "interactive" | "planner" | "refiner" | "worker" | "reviewer".
702    pub role: String,
703    /// When this session was recorded on the task (unix millis).
704    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    /// Unix seconds of the session's last activity (any message append).
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub last_activity: Option<i64>,
713    /// Last known phase ("idle" | "thinking" | "responding" | "tool_exec" | ...).
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub last_phase: Option<String>,
716    /// Exit status if the session has finished a turn:
717    /// "completed" | "error" | "cancelled" | "max_turns".  None while live or
718    /// if the session has never run a turn.
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub last_exit_status: Option<String>,
721    /// True when a chat turn is actively running for this session right now.
722    #[serde(default)]
723    pub is_live: bool,
724}
725
726/// Per-task wait-reason bundle attached to a `TaskOverview` response.
727///
728/// The scheduler classifies every non-dispatched task into one or more
729/// [`TaskWaitReason`]s; the TUI uses this both for inline `⏳ #N`
730/// suffixes (dependency reasons) and the full detail-overlay list.
731#[derive(Debug, Clone, Serialize, Deserialize)]
732pub struct TaskWaitReasons {
733    pub task_id: i64,
734    pub reasons: Vec<TaskWaitReason>,
735}
736
737/// Why a task is waiting / not yet dispatched. Mirrors the plugin-side
738/// `WaitReason` enum in `tau-agent-plugin-tasks`.
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
740pub enum TaskWaitReason {
741    /// Blocked by a dependency that hasn't completed yet.
742    Dependency {
743        task_id: i64,
744        title: String,
745        state: String,
746        project_name: String,
747    },
748    /// Affected files overlap with an active/in-flight task.
749    FileConflict {
750        files: Vec<String>,
751        with_task_id: i64,
752    },
753    /// Concurrent task budget exhausted.
754    BudgetExhausted { used: usize, max: usize },
755    /// The merge_target branch does not exist in the repository.
756    MergeTargetNotFound { branch: String },
757    /// In ready/planning state but not yet scheduled.
758    NotScheduled,
759}
760
761/// Entry in the task history log (`task_history` table).
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
764pub struct TaskHistoryInfo {
765    /// Field that was updated: "state", "priority", "held", "affected_files",
766    /// "title", ...
767    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    /// Session that performed the update, if known.
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub session_id: Option<String>,
775    /// Unix millis.
776    pub created_at: i64,
777}
778
779/// Cumulative session usage statistics.
780#[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    /// Whether credentials are OAuth (subscription).
789    pub is_subscription: bool,
790    /// Context window info from the model.
791    pub context_window: u64,
792    /// Estimated context usage from last assistant response (input tokens).
793    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/// Project-wide usage / cost totals, aggregated across every session
811/// (archived included) belonging to a project.
812///
813/// Returned by the `ProjectStats` request.  No per-model breakdown in v1.
814#[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    /// Unix-seconds timestamp of the most recent message in any of the
825    /// project's sessions, or `None` if the project has no messages yet.
826    #[serde(skip_serializing_if = "Option::is_none")]
827    pub last_activity: Option<i64>,
828}
829
830/// Project metadata returned by the `GetProjectInfo` request.
831///
832/// Currently a thin wrapper over the DB row; only `name` and `path` are
833/// surfaced because callers (e.g. the worker bash fallback) only need
834/// the root path. Extend as needed.
835#[derive(Debug, Clone, Serialize, Deserialize)]
836pub struct ProjectInfoEntry {
837    pub name: String,
838    pub path: String,
839}
840
841/// Format a token count for display: 1234 → "1.2K", 1234567 → "1.2M".
842pub 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/// Format session stats as a compact one-line summary like pi's footer:
853/// `↑12K ↓81K R18M W353K $13.434 (sub) 18.4%/200K`
854#[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
896/// Format a `resets_at` ISO-8601 timestamp as a compact time-until-reset string.
897/// Returns "?" if the timestamp can't be parsed or is in the past.
898fn format_resets_at(resets_at: &str) -> String {
899    // Parse ISO-8601 timestamps like "2026-04-03T18:30:00Z" or with fractional seconds.
900    // We do minimal parsing to avoid pulling in chrono.
901    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    // Strip fractional seconds and timezone offset beyond 'Z'
912    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    // Convert to Unix timestamp (approximate — ignores leap seconds, good enough for display).
925    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
948/// Format seconds as compact duration: "16h", "2d", "45m".
949fn 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
961/// Format utilization (already 0–100 from the API) as a compact percentage string.
962pub fn format_utilization(utilization: Option<f64>) -> String {
963    match utilization {
964        Some(u) => format!("{:.0}%", u),
965        None => "?".into(),
966    }
967}
968
969/// Format a single usage bucket as `"LABEL PCT RESET"`.
970fn 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
983/// Format subscription usage as a compact footer string.
984///
985/// Example: `(5h 50% 16h | 7d 12% 2d | sonnet 6% 1d)`
986///
987/// Returns `None` if there's no usage data to display.
988pub 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        // Bucket with no utilization is skipped
1136        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    /// Verify that all new task-related protocol variants round-trip through serde.
1150    #[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        // Request variants
1189        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        // Response variants
1251        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        // Old client payloads omit the `attachments` field entirely.
1405        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}