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    /// Mark `session_id` as superseded by `successor_id`. Future
128    /// notifications / queued messages / new-child-parent-anchor lookups
129    /// targeted at `session_id` are forwarded to the resolved tip of the
130    /// successor chain.  `successor_id == None` clears the link (un-retire).
131    ///
132    /// The predecessor stays in the DB — message history is preserved
133    /// and remains readable — but it will not receive new wakeups while
134    /// a successor is set.  See task 914.
135    SetSessionSuccessor {
136        session_id: String,
137        #[serde(default, skip_serializing_if = "Option::is_none")]
138        successor_id: Option<String>,
139        /// Session id of the caller when invoked via an orchestration tool.
140        /// `None` when invoked by the TUI/CLI/external API.
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        caller_session_id: Option<String>,
143    },
144    /// Resolve the live tip of `session_id`'s successor chain.
145    ///
146    /// Returns [`Response::ResolvedSuccessor`].  Used by plugins (notably
147    /// the tasks plugin) to redirect notifications away from retired
148    /// sessions.  See task 914.
149    ResolveSuccessor { session_id: String },
150    /// Atomically create a new session inheriting `session_id`'s
151    /// `model` / `cwd` / `system_prompt` / `project_name` / `child_budget`,
152    /// then mark `session_id` as retired by setting its `successor_id`
153    /// to the new session's id.
154    ///
155    /// The new session is always **top-level** (`parent_id = None`) so
156    /// succession does not change the predecessor's place in the session
157    /// tree.  Returns [`Response::SessionCreated`] with the successor id
158    /// on success and broadcasts [`Response::SessionSucceeded`] on the
159    /// predecessor's subscriber channel.  See task 915.
160    SucceedSession {
161        session_id: String,
162        #[serde(default, skip_serializing_if = "Option::is_none")]
163        tagline: Option<String>,
164        /// Session id of the caller when invoked via an orchestration tool
165        /// (e.g. `session_succeed`).  `None` when invoked via the TUI/CLI.
166        #[serde(default, skip_serializing_if = "Option::is_none")]
167        caller_session_id: Option<String>,
168    },
169    /// Look up whether `session_id` is recorded as a session of any
170    /// non-terminal task and, if so, return the `(task_id, role)` it
171    /// plays.  Used by orchestration tools (today: `session_succeed`)
172    /// that must refuse to disturb a task-managed session lifecycle.
173    ///
174    /// Returns [`Response::TaskSessionRole`] always — a non-task session
175    /// yields `is_worker = false` with `task_id` / `role` set to `None`.
176    /// See task 915.
177    GetTaskSessionRole { session_id: String },
178    /// Start OAuth login for a provider.
179    Login { provider: String },
180    /// Query authentication status.
181    AuthStatus,
182    /// Fetch subscription usage (OAuth only, cached 5 min).
183    GetSubscriptionUsage,
184    /// Get message history for a session.
185    GetMessages { session_id: String },
186    /// Subscribe to live events on a session (for multi-client).
187    /// The connection stays open and receives Stream/AgentDone/Cancelled events.
188    Subscribe { session_id: String },
189    /// Wait for sessions to complete.
190    WaitSessions {
191        session_ids: Vec<String>,
192        #[serde(default = "default_wait_timeout")]
193        timeout_secs: u64,
194    },
195    /// Wait for any of the specified sessions to complete (returns as soon as >= 1 is done).
196    WaitAnySessions {
197        session_ids: Vec<String>,
198        #[serde(default = "default_wait_timeout")]
199        timeout_secs: u64,
200    },
201    /// Cancel an in-progress chat (agent loop) for a session.
202    CancelChat {
203        session_id: String,
204        /// Session id of the caller when invoked via an orchestration tool
205        /// (e.g. `session_cancel` from another session). `None` when invoked
206        /// via the TUI/CLI/external API.
207        #[serde(default, skip_serializing_if = "Option::is_none")]
208        caller_session_id: Option<String>,
209    },
210    /// Inject a steering message into a running agent loop.
211    /// The message is inserted as a user message between tool results
212    /// and the next LLM call. If no agent is running, treated as Chat.
213    Steer { session_id: String, text: String },
214    /// Trigger context compaction now. Optional `keep_hint` is free-form
215    /// text the summarizer is asked to preserve in addition to its standard
216    /// sections (advisory, not a hard filter).
217    Compact {
218        session_id: String,
219        #[serde(default, skip_serializing_if = "Option::is_none")]
220        keep_hint: Option<String>,
221    },
222
223    /// Queue a message for delivery to a target session.
224    /// When `await_reply` is true the caller blocks until the target
225    /// calls `session_reply` with the corresponding `msg_id`.
226    QueueMessage {
227        target_session_id: String,
228        content: String,
229        sender_info: String,
230        /// When true, block until the target replies.
231        #[serde(default)]
232        await_reply: bool,
233        /// For threaded replies: the msg_id this message is responding to.
234        #[serde(skip_serializing_if = "Option::is_none")]
235        reply_to: Option<String>,
236    },
237    /// Persist a zero-token display-only info message to a session's
238    /// message history. Unlike `QueueMessage`, this does **not** wake the
239    /// agent loop and the message is excluded from LLM context.
240    ///
241    /// Intended for observational notifications such as task state-change
242    /// info-lines surfaced in the TUI.
243    QueueInfo {
244        target_session_id: String,
245        text: String,
246    },
247    /// Reply to a pending `await_reply` message.
248    ReplyToMessage { msg_id: String, content: String },
249    /// Reload plugins for a session (destroy + re-init).
250    ReloadPlugins { session_id: String },
251    /// Re-read `providers.toml` and global `models.toml` without restarting
252    /// the server. On success, the in-memory provider/model tables and the
253    /// global alias map are swapped in; on error (IO / parse failure) the
254    /// existing state is left untouched and the server returns
255    /// [`Response::Error`] so a broken edit can't brick a running server.
256    ///
257    /// Narrow by design: this does **not** reload plugins (see
258    /// [`Request::ReloadPlugins`]), `auth.json` (re-read per request),
259    /// or per-project `.tau/models.toml` (re-read per lookup).
260    ReloadConfig,
261    /// Garbage-collect archived sessions older than a threshold.
262    GcSessions {
263        /// Delete archived sessions older than this many days.
264        older_than_days: u64,
265    },
266    /// Broadcast a hook to other plugins (plugin-to-plugin communication).
267    FireHook {
268        name: String,
269        data: serde_json::Value,
270    },
271    /// Execute a tool directly on a session (no LLM involved).
272    ExecuteTool {
273        session_id: String,
274        tool_name: String,
275        arguments: serde_json::Value,
276    },
277    /// Enqueue a Tier-3 post-idle action for the given session. The server
278    /// drains the queue once the session's lock releases (after the agent
279    /// loop exits). Intended for side effects that need exclusive access
280    /// to the caller's session or its subtree (e.g. archival, merge pass).
281    ///
282    /// See [`crate::types::PostIdleAction`] for the action semantics.
283    EnqueuePostIdleAction {
284        session_id: String,
285        action: crate::types::PostIdleAction,
286    },
287    /// Set the tagline for a session.
288    SetTagline { session_id: String, tagline: String },
289    /// List tasks for a project.
290    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    /// Get full details of a task.
298    TaskGet { id: i64 },
299    /// Create a new task.
300    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    /// Update a task (state, title, priority, etc.).
313    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    /// Search tasks by query.
333    TaskSearch {
334        project: String,
335        query: String,
336        #[serde(skip_serializing_if = "Option::is_none")]
337        state: Option<String>,
338    },
339    /// Assign a task to a session.
340    TaskAssign { id: i64, session_id: String },
341    /// Get scheduler status.
342    TaskStatus { project: String },
343    /// Structured task overview for interactive rendering.
344    ///
345    /// Returns active/queued/blocked/held tasks plus a bounded tail of
346    /// recently-terminated (`merged` / `closed`) tasks, all as `TaskInfo`
347    /// rather than pre-formatted text.  Consumers (the TUI task picker)
348    /// render the overview grouped by scheduler position.
349    ///
350    /// `recent_limit` applies **per bucket** — up to `recent_limit` merged
351    /// tasks **plus** up to `recent_limit` closed tasks, so the tail length
352    /// is at most `2 * recent_limit`.
353    TaskOverview {
354        project: String,
355        /// Max number of recently-terminated tasks to include *per bucket*
356        /// (merged and closed are capped separately).  Defaults to 10.
357        #[serde(default = "default_recent_limit")]
358        recent_limit: usize,
359    },
360    /// Get merge queue (approved + merging tasks).
361    TaskMergeQueue { project: String },
362    /// Project-wide aggregate usage / cost stats.
363    ///
364    /// Returns totals across every session (archived included) belonging
365    /// to `project_name`.
366    ProjectStats { project_name: String },
367    /// Look up a project by name. Returns the project's root path so the
368    /// caller can recover when a session's `cwd` has disappeared
369    /// (worktree removed, etc.) and the worker wants to fall back to the
370    /// project root before executing a bash command. See task 720.
371    GetProjectInfo { project_name: String },
372    /// Shut down the server.
373    Shutdown {
374        /// If true, server is restarting (clients should reconnect).
375        #[serde(default)]
376        restart: bool,
377    },
378}
379
380/// Attachments to a `Request::Chat` message.
381///
382/// Today only images are supported; the structure is an open enum so we can
383/// add more attachment kinds without bumping the protocol shape.
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
385#[serde(tag = "type", rename_all = "snake_case")]
386pub enum ChatAttachment {
387    /// An image. `data` is base64-encoded image bytes; `mime_type` is one
388    /// of the MIME types accepted by the validator (`image/png`, `image/jpeg`,
389    /// `image/gif`, `image/webp`). The server validates both fields before
390    /// building the `UserMessage` and rejects oversized or malformed payloads
391    /// with a `Response::Error` rather than panicking.
392    Image { data: String, mime_type: String },
393}
394
395impl ChatAttachment {
396    /// Convert this attachment into an engine `UserContent` block.
397    ///
398    /// Pure structural mapping; callers that need validation (decoded byte
399    /// length, allowed MIME, etc.) should run that *before* calling this.
400    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// ---------------------------------------------------------------------------
413// Server → Client
414// ---------------------------------------------------------------------------
415
416#[derive(Debug, Serialize, Deserialize, Clone)]
417#[serde(tag = "type", rename_all = "snake_case")]
418pub enum Response {
419    /// Session was created.
420    SessionCreated { session_id: String },
421    /// Info about a single session.
422    SessionInfo { info: SessionInfo },
423    /// Ancestor chain for a session, leaf-first.  See `Request::GetSessionAncestors`.
424    SessionAncestors { sessions: Vec<SessionInfo> },
425    /// List of sessions.
426    Sessions { sessions: Vec<SessionInfo> },
427    /// Session deleted.
428    SessionDeleted,
429    /// Session archived.
430    SessionArchived,
431    /// Session restored (un-archived).
432    SessionRestored,
433    /// Available models.
434    Models { models: Vec<ModelInfo> },
435    /// Configured aliases (global + per-project).
436    ///
437    /// Added in protocol v0.2.  Older clients will not understand this
438    /// variant and will fall through to their default error path.
439    Aliases {
440        #[serde(default)]
441        global: Vec<AliasInfo>,
442        #[serde(default)]
443        project: Vec<AliasInfo>,
444    },
445    /// Model changed.
446    ModelChanged { model: ModelInfo },
447    /// Streaming event from the LLM.
448    Stream { event: Box<StreamEvent> },
449    /// OAuth login succeeded.
450    LoginSuccess { provider: String },
451    /// Authentication status.
452    AuthStatus { providers: Vec<String> },
453    /// Subscription usage data.
454    SubscriptionUsage { usage: SubscriptionUsage },
455    /// Server is shutting down. Clients should reconnect if restart=true.
456    ServerShutdown { restart: bool },
457    /// Sessions completed (response to WaitSessions).
458    SessionsCompleted { results: Vec<SessionResult> },
459    /// Agent loop was cancelled by the user.
460    Cancelled,
461    /// Message history for a session.
462    Messages {
463        messages: Vec<crate::types::Message>,
464    },
465    /// A user message was sent (broadcast to subscribers).
466    UserMessage { text: String },
467    /// Agent loop completed (all turns done).
468    AgentDone,
469    /// Reply content (returned to a QueueMessage with await_reply=true).
470    MessageReply { content: String },
471    /// Success (generic ack).
472    Ok,
473    /// Success, with an advisory note from the server.
474    ///
475    /// Emitted in place of `Ok` when the server wants to tell the caller
476    /// something about how the request was handled without treating it as
477    /// an error. Today: `QueueMessage` (fire-and-forget) targeting a
478    /// placeholder (log-provider) session — the note explains that the
479    /// message was recorded but no agent loop ran. See task 582.
480    ///
481    /// Older clients that don't know this variant will fall through to
482    /// their default-error path; the request still succeeded on the
483    /// server side.
484    OkWithNote { note: String },
485    /// Garbage-collection result.
486    GcComplete { deleted: usize },
487    /// Tool execution result (response to ExecuteTool).
488    ToolExecuted { content: String, is_error: bool },
489    /// List of tasks (flat, for search/merge queue results).
490    TaskList { tasks: Vec<TaskInfo> },
491    /// Full task details (response to TaskGet).
492    TaskDetail {
493        task: TaskInfo,
494        messages: Vec<TaskMessageInfo>,
495        relations: Vec<TaskRelationInfo>,
496        subtasks: Vec<TaskInfo>,
497        /// Every `(session_id, role)` pair recorded for this task, enriched
498        /// with best-effort session state.  Missing / deleted / cross-project
499        /// sessions are dropped silently.  Back-compat: older clients that
500        /// don't know about this field ignore it.
501        #[serde(default, skip_serializing_if = "Vec::is_empty")]
502        sessions: Vec<TaskSessionInfo>,
503        /// State transitions and other task updates in chronological order.
504        #[serde(default, skip_serializing_if = "Vec::is_empty")]
505        history: Vec<TaskHistoryInfo>,
506    },
507    /// Task created or updated (response to TaskCreate, TaskUpdate, TaskAssign).
508    TaskUpdated { task: TaskInfo },
509    /// Scheduler status text (response to TaskStatus).
510    TaskStatus { text: String },
511    /// Structured scheduler overview (response to TaskOverview).
512    TaskOverview {
513        /// Tasks in active lifecycle states (active, review, merging, refining).
514        active: Vec<TaskInfo>,
515        /// Tasks ready to dispatch (state=ready, not held, deps satisfied).
516        queued_ready: Vec<TaskInfo>,
517        /// Tasks queued for planning (state=planning, deps satisfied).
518        queued_planning: Vec<TaskInfo>,
519        /// Tasks blocked by unmet dependencies (state=ready or planning).
520        blocked: Vec<TaskInfo>,
521        /// Tasks held (state=ready or planning, held=true).
522        held: Vec<TaskInfo>,
523        /// Most recently merged tasks, newest first, capped at `recent_limit`
524        /// (the request's per-bucket limit).
525        recently_merged: Vec<TaskInfo>,
526        /// Most recently "done" tasks (no_merge tasks that completed without
527        /// producing a code change), newest first, capped at `recent_limit`.
528        #[serde(default)]
529        recently_done: Vec<TaskInfo>,
530        /// Most recently closed tasks, newest first, capped at `recent_limit`
531        /// (the request's per-bucket limit; merged and closed are independent).
532        recently_closed: Vec<TaskInfo>,
533        /// Current in-flight count (active/review/merging/refining).
534        inflight_count: usize,
535        /// Scheduler's max-concurrent budget.
536        max_concurrent: usize,
537        /// For each queued/blocked task id, the full list of wait reasons
538        /// keeping it from dispatch. Dependency reasons drive the inline
539        /// `⏳ #N` suffix in the picker; the detail pane renders every
540        /// reason verbatim.
541        #[serde(default, skip_serializing_if = "Vec::is_empty")]
542        wait_reasons: Vec<TaskWaitReasons>,
543    },
544    /// Task list with tree depth info (response to TaskList).
545    TaskTree { tasks: Vec<(usize, TaskInfo)> },
546    /// Merge queue (approved + merging tasks, response to TaskMergeQueue).
547    TaskMergeQueue { tasks: Vec<TaskInfo> },
548    /// Project-wide usage / cost totals (response to `ProjectStats`).
549    ProjectStats { stats: ProjectStatsInfo },
550    /// Project metadata (response to `GetProjectInfo`).
551    ///
552    /// `project` is `None` when the named project does not exist; this is
553    /// not treated as an error response so callers can match on "unknown
554    /// project" cleanly.
555    ProjectInfo { project: Option<ProjectInfoEntry> },
556    /// Resolved tip of a session's successor chain (response to
557    /// `Request::ResolveSuccessor`).  Returns the input session id
558    /// unchanged when no successor is set or the chain dead-ends at an
559    /// archived / missing successor.  See task 914.
560    ResolvedSuccessor { session_id: String },
561    /// Broadcast on the predecessor's subscriber channel when the session
562    /// has been retired in favour of `successor_id`.  Subscribed clients
563    /// (e.g. the TUI) typically react by switching their view to the
564    /// successor.  See task 915.
565    SessionSucceeded { successor_id: String },
566    /// Response to [`Request::GetTaskSessionRole`].  When `is_worker`
567    /// is true the session is currently bound to a non-terminal task in
568    /// the role identified by `role` (typically `"worker"`); `task_id`
569    /// names the task.  When `is_worker` is false (no task linkage),
570    /// `task_id` and `role` are `None`.  See task 915.
571    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.
579    Error { message: String },
580}
581
582/// Sentinel message used by `Response::Error { message }` when a request
583/// was refused because the server is in the shutdown-drain window.
584///
585/// Clients recognise this exact string to distinguish "server is
586/// transitioning" (reconnect + retry) from "this specific operation
587/// failed" (surface the error). Kept as a plain constant rather than a
588/// dedicated enum variant so that older clients that only know about
589/// `Response::Error` still see a human-readable message in the UI.
590pub const SHUTTING_DOWN_ERROR: &str = "__tau_server_shutting_down__";
591
592/// Returns true if `err` is the distinctive "server is shutting down"
593/// signal produced by the server during its drain window. Used by
594/// clients to trigger reconnect/retry paths instead of surfacing the
595/// error to the user.
596pub fn is_shutting_down_error(err: &str) -> bool {
597    err == SHUTTING_DOWN_ERROR || err.contains("server is shutting down")
598}
599
600/// Returns true if `err` looks like a failure surfaced by the
601/// Anthropic subscription-usage poll path (`/v1/messages/usage` /
602/// `/api/oauth/usage`). Used by clients as defence-in-depth: the
603/// server-side handler in #940 no longer sends `Response::Error` for
604/// these failures — it falls back to a cached or default
605/// [`Response::SubscriptionUsage`] — but if a future code path were to
606/// regress and emit such an error over the wire, clients can
607/// recognize it as out-of-band and refrain from tearing down
608/// streaming UI state (in-flight tool calls, agent phase, etc.).
609pub 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    /// Unix timestamp (seconds) of last activity (last message or session creation).
622    pub last_activity: i64,
623    /// Parent session ID (None for root sessions).
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub parent_id: Option<String>,
626    /// Number of direct child sessions.
627    #[serde(default)]
628    pub child_count: usize,
629    /// Budget for descendant sessions.
630    #[serde(default)]
631    pub child_budget: u32,
632    /// Short description of what this session is working on.
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub tagline: Option<String>,
635    /// Current agent phase: "idle", "thinking", "responding", "tool_exec", etc.
636    #[serde(default = "default_state")]
637    pub state: String,
638    /// Context usage as percentage (0-100), if known.
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub context_pct: Option<f64>,
641    /// Whether this session is archived.
642    #[serde(default)]
643    pub archived: bool,
644    /// Project name this session belongs to.
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub project_name: Option<String>,
647    /// Optional successor session id.  When `Some`, this session has
648    /// been retired and notifications targeted at it are forwarded to
649    /// the resolved tip of the successor chain.  See task 914.
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub successor_id: Option<String>,
652    /// Last exit status: null (never ran), "completed", "error", "cancelled", "max_turns".
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub last_exit_status: Option<String>,
655    /// True when a chat turn is actively running for this session right now.
656    /// False means the session is idle — `state` may reflect a stale phase
657    /// from a previous turn or server restart.
658    #[serde(default)]
659    pub is_live: bool,
660    /// Unix-ms timestamp when the current non-Idle turn began on the
661    /// server. `Some(_)` while a turn is in flight, `None` when the
662    /// session is idle. Used by the TUI to anchor the "Working... Xs"
663    /// counter so it remains correct when attaching to an already-running
664    /// session from the picker.
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub turn_started_at_ms: Option<u64>,
667    /// Unix-ms timestamp when the current phase began on the server.
668    /// Re-stamped on every phase transition within a turn; `None` when
669    /// the session is idle. Symmetric to `turn_started_at_ms` so
670    /// late-subscribing clients can anchor the per-phase elapsed counter.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub phase_started_at_ms: Option<u64>,
673}
674
675/// Result for a single session in WaitSessions response.
676#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct SessionResult {
678    pub session_id: String,
679    /// "done", "error", "cancelled", "timeout"
680    pub status: String,
681    /// Last assistant message text (truncated).
682    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/// One configured alias entry: a short name pointing at a target.
712///
713/// Targets are model ids, optionally prefixed with `provider/`.  See
714/// [`crate::model_resolve`] for resolution rules.
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct AliasInfo {
717    /// The short name users type, e.g. `"smart"`.
718    pub name: String,
719    /// What the alias points at, e.g. `"claude-opus-4-6"` or
720    /// `"openai/gpt-4.1-mini"`.
721    pub target: String,
722}
723
724/// Task info for wire protocol (mirrors tasks_db::Task but protocol-owned).
725#[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    /// Best-effort hint: true when any session recorded on this task is
745    /// currently running a chat turn.  Populated server-side for the
746    /// TaskList / TaskTree / TaskDetail responses; defaults to false for
747    /// back-compat with older clients / serialised payloads.
748    #[serde(default)]
749    pub has_live_session: bool,
750    /// Project the task was *filed from* — the calling session's
751    /// project at `task_create` time. Distinct from
752    /// [`TaskInfo::project_name`], which is where the work runs. Equal
753    /// for same-project filing, different for cross-project filing
754    /// (#750). `None` for tasks created before #758. See
755    /// `tasks_db::Task::filed_by_project` for full semantics.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub filed_by_project: Option<String>,
758    /// Session id of the caller that ran `task_create`. `None` for
759    /// tasks created before #758, or when no calling session was
760    /// available.
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub filed_by_session_id: Option<String>,
763    /// True when this task does not produce a code change (no_merge tasks).
764    /// The scheduler skips worktree creation and the merge ceremony; on
765    /// approval the task transitions directly to `done`. Defaults to
766    /// `false` for back-compat with older clients / serialised payloads.
767    #[serde(default)]
768    pub no_merge: bool,
769    pub created_at: i64,
770    pub updated_at: i64,
771}
772
773/// Task message info for wire protocol.
774#[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/// Task relation info for wire protocol.
785#[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/// Session recorded against a task, enriched with best-effort live state.
793///
794/// One `TaskSessionInfo` per `(task_id, session_id, role)` row in
795/// `task_sessions`.  Enrichment fields are `Option<T>` because a session may
796/// have been deleted, archived to a different store, or be otherwise
797/// unreadable — we still want to surface the bare `(session_id, role)` row.
798#[derive(Debug, Clone, Serialize, Deserialize)]
799pub struct TaskSessionInfo {
800    /// Session ID.
801    pub session_id: String,
802    /// Role: "creator" | "interactive" | "planner" | "refiner" | "worker" | "reviewer".
803    pub role: String,
804    /// When this session was recorded on the task (unix millis).
805    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    /// Unix seconds of the session's last activity (any message append).
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub last_activity: Option<i64>,
814    /// Last known phase ("idle" | "thinking" | "responding" | "tool_exec" | ...).
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub last_phase: Option<String>,
817    /// Exit status if the session has finished a turn:
818    /// "completed" | "error" | "cancelled" | "max_turns".  None while live or
819    /// if the session has never run a turn.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub last_exit_status: Option<String>,
822    /// True when a chat turn is actively running for this session right now.
823    #[serde(default)]
824    pub is_live: bool,
825}
826
827/// Per-task wait-reason bundle attached to a `TaskOverview` response.
828///
829/// The scheduler classifies every non-dispatched task into one or more
830/// [`TaskWaitReason`]s; the TUI uses this both for inline `⏳ #N`
831/// suffixes (dependency reasons) and the full detail-overlay list.
832#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct TaskWaitReasons {
834    pub task_id: i64,
835    pub reasons: Vec<TaskWaitReason>,
836}
837
838/// Why a task is waiting / not yet dispatched. Mirrors the plugin-side
839/// `WaitReason` enum in `tau-agent-plugin-tasks`.
840#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
841pub enum TaskWaitReason {
842    /// Blocked by a dependency that hasn't completed yet.
843    Dependency {
844        task_id: i64,
845        title: String,
846        state: String,
847        project_name: String,
848    },
849    /// Affected files overlap with an active/in-flight task.
850    FileConflict {
851        files: Vec<String>,
852        with_task_id: i64,
853    },
854    /// Concurrent task budget exhausted.
855    BudgetExhausted { used: usize, max: usize },
856    /// The merge_target branch does not exist in the repository.
857    MergeTargetNotFound { branch: String },
858    /// In ready/planning state but not yet scheduled.
859    NotScheduled,
860}
861
862/// Entry in the task history log (`task_history` table).
863
864#[derive(Debug, Clone, Serialize, Deserialize)]
865pub struct TaskHistoryInfo {
866    /// Field that was updated: "state", "priority", "held", "affected_files",
867    /// "title", ...
868    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    /// Session that performed the update, if known.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub session_id: Option<String>,
876    /// Unix millis.
877    pub created_at: i64,
878}
879
880/// Cumulative session usage statistics.
881#[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    /// Whether credentials are OAuth (subscription).
890    pub is_subscription: bool,
891    /// Context window info from the model.
892    pub context_window: u64,
893    /// Estimated context usage from last assistant response (input tokens).
894    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/// Project-wide usage / cost totals, aggregated across every session
912/// (archived included) belonging to a project.
913///
914/// Returned by the `ProjectStats` request.  No per-model breakdown in v1.
915#[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    /// Unix-seconds timestamp of the most recent message in any of the
926    /// project's sessions, or `None` if the project has no messages yet.
927    #[serde(skip_serializing_if = "Option::is_none")]
928    pub last_activity: Option<i64>,
929}
930
931/// Project metadata returned by the `GetProjectInfo` request.
932///
933/// Currently a thin wrapper over the DB row; only `name` and `path` are
934/// surfaced because callers (e.g. the worker bash fallback) only need
935/// the root path. Extend as needed.
936#[derive(Debug, Clone, Serialize, Deserialize)]
937pub struct ProjectInfoEntry {
938    pub name: String,
939    pub path: String,
940}
941
942/// Format a token count for display: 1234 → "1.2K", 1234567 → "1.2M".
943pub 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/// Format session stats as a compact one-line summary like pi's footer:
954/// `↑12K ↓81K R18M W353K $13.434 (sub) 18.4%/200K`
955#[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
997/// Format a `resets_at` ISO-8601 timestamp as a compact time-until-reset string.
998/// Returns "?" if the timestamp can't be parsed or is in the past.
999fn format_resets_at(resets_at: &str) -> String {
1000    // Parse ISO-8601 timestamps like "2026-04-03T18:30:00Z" or with fractional seconds.
1001    // We do minimal parsing to avoid pulling in chrono.
1002    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    // Strip fractional seconds and timezone offset beyond 'Z'
1013    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    // Convert to Unix timestamp (approximate — ignores leap seconds, good enough for display).
1026    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
1049/// Format seconds as compact duration: "16h", "2d", "45m".
1050fn 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
1062/// Format utilization (already 0–100 from the API) as a compact percentage string.
1063pub fn format_utilization(utilization: Option<f64>) -> String {
1064    match utilization {
1065        Some(u) => format!("{:.0}%", u),
1066        None => "?".into(),
1067    }
1068}
1069
1070/// Format a single usage bucket as `"LABEL PCT RESET"`.
1071fn 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
1084/// Format subscription usage as a compact footer string.
1085///
1086/// Example: `(5h 50% 16h | 7d 12% 2d | sonnet 6% 1d)`
1087///
1088/// Returns `None` if there's no usage data to display.
1089pub 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        // Bucket with no utilization is skipped
1237        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    /// Verify that all new task-related protocol variants round-trip through serde.
1251    #[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        // Request variants
1291        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        // Response variants
1379        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        // Old client payloads omit the `attachments` field entirely.
1550        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}