Skip to main content

scv_protocol/
lib.rs

1//! Dependency-light wire types shared by SCV clients and the server.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const PROTOCOL_VERSION: u32 = 3;
7
8/// The longest `session.start` channel name.
9pub const MAX_CHANNEL_NAME_BYTES: usize = 32;
10/// Files one `turn.start` may attach.
11pub const MAX_TURN_ATTACHMENTS: usize = 16;
12/// The tool a chat session's model calls to send a file with its reply.
13pub const CHAT_ATTACH_TOOL: &str = "chat_attach";
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum ComponentState {
18    Disabled,
19    Starting,
20    Connected,
21    Disconnected,
22    Backoff,
23    Stopping,
24    Stopped,
25    Failed,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ComponentHealth {
30    pub id: String,
31    /// The chat channel this account belongs to, such as `wechat`.
32    #[serde(default)]
33    pub channel: String,
34    pub account: String,
35    pub bot_id: Option<String>,
36    pub user_id: Option<String>,
37    pub enabled: bool,
38    pub state: ComponentState,
39    pub last_success_unix_seconds: Option<u64>,
40    pub error: Option<String>,
41    pub restarts: u64,
42    /// Effective remote tool authority; `owner` only when the owner ID is known.
43    #[serde(default)]
44    pub remote_tools: RemoteTools,
45}
46
47/// Who may use tools through a remote bridge account.
48#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum RemoteTools {
51    /// Every remote session is tool-free (the default).
52    #[default]
53    None,
54    /// The account's authenticated owner gets full, auto-approved tools.
55    Owner,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct DaemonStatus {
60    pub version: String,
61    pub pid: u32,
62    pub components: Vec<ComponentHealth>,
63    #[serde(default)]
64    pub delegations: DelegationSummary,
65    /// A restart the daemon has scheduled, if any.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub restart: Option<RestartInfo>,
68}
69
70/// A restart into a newly installed release, waiting for owner work to end.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct RestartInfo {
73    /// The release it restarts into.
74    pub to_version: String,
75    /// What it still waits for, such as the requesting delegation or an
76    /// owner's message; `None` once it restarts.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub waiting_for: Option<String>,
79    /// The delegation that asked, whose report goes out first.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub requester: Option<String>,
82    /// The chat the announcement goes to, as `<channel>:<account>`.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub origin: Option<String>,
85    /// When it restarts even if work is still running.
86    pub deadline_unix_seconds: u64,
87}
88
89/// Delegated agent runs of the daemon's SCV instance.
90#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
91pub struct DelegationSummary {
92    /// Running delegations, whichever SCV process of the instance started them.
93    pub active: u64,
94    /// Orphaned delegations the daemon has stopped since it started.
95    pub reaped: u64,
96    /// Listed delegations, for `delegations` and `delegation_kill`.
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub entries: Vec<DelegationInfo>,
99    /// Handles this request stopped.
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub killed: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct DelegationInfo {
106    pub handle: String,
107    pub agent: String,
108    pub session: String,
109    pub depth: u32,
110    pub pid: u32,
111    /// The SCV process that started it.
112    pub owner_pid: u32,
113    /// Live processes in its group plus tagged processes outside it.
114    pub processes: u32,
115    pub cwd: String,
116    pub started_unix_seconds: u64,
117    /// The owning SCV process is gone; the daemon will stop it.
118    pub orphaned: bool,
119    /// The conversation this run is a turn of, and which turn.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub conversation: Option<String>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub turn: Option<u32>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127#[serde(tag = "action", rename_all = "snake_case")]
128pub enum DaemonCommand {
129    Status,
130    Reload,
131    /// Enable or disable one channel account, optionally changing its
132    /// workspace and remote tool grant.
133    ChannelSet {
134        channel: String,
135        account: String,
136        enabled: bool,
137        workspace: Option<String>,
138        /// Omitted keeps the saved setting.
139        #[serde(default, skip_serializing_if = "Option::is_none")]
140        remote_tools: Option<RemoteTools>,
141    },
142    /// Stop one channel account and remove its credentials and state.
143    ChannelLogout {
144        channel: String,
145        account: String,
146    },
147    /// List running delegations; `all` includes orphans awaiting cleanup.
148    Delegations {
149        #[serde(default)]
150        all: bool,
151    },
152    /// Stop one delegation by handle, or every orphaned one.
153    DelegationKill {
154        #[serde(default, skip_serializing_if = "Option::is_none")]
155        handle: Option<String>,
156        #[serde(default)]
157        orphans: bool,
158    },
159    /// Restart into the release installed at the daemon's own path once the
160    /// requesting delegation has finished and its report is stored and no
161    /// owner message is being answered, or at `max_wait_seconds` anyway.
162    RestartWhenIdle {
163        /// The release the caller installed; the daemon checks it.
164        #[serde(default, skip_serializing_if = "Option::is_none")]
165        version: Option<String>,
166        /// The commit it was built from, for the announcement.
167        #[serde(default, skip_serializing_if = "Option::is_none")]
168        commit: Option<String>,
169        /// The caller's `SCV_PARENT` chain, naming the delegation to wait for.
170        #[serde(default, skip_serializing_if = "Option::is_none")]
171        parent: Option<String>,
172        #[serde(default, skip_serializing_if = "Option::is_none")]
173        max_wait_seconds: Option<u64>,
174    },
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
178pub struct QueueEntry {
179    pub queue_id: String,
180    pub revision: u64,
181    pub prompt: String,
182    pub submitter: String,
183    /// Files the queued `turn.start` attached; they run with its prompt.
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub attachments: Vec<Attachment>,
186}
187
188/// A file a client attaches to its turn, already saved on the daemon's host,
189/// such as a photo or document a chat user sent.
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct Attachment {
192    /// `image`, `audio`, `video`, `file`, or `sticker`.
193    pub kind: String,
194    /// Absolute path of a regular file on the daemon's host.
195    pub path: String,
196    /// The name the sender gave it; empty when the platform has none.
197    #[serde(default, skip_serializing_if = "String::is_empty")]
198    pub name: String,
199    /// MIME type, such as `image/png`; empty when unknown.
200    #[serde(default, skip_serializing_if = "String::is_empty")]
201    pub mime: String,
202    /// Size in bytes.
203    pub size: u64,
204    /// What a voice message said, when the platform transcribed it.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub transcript: Option<String>,
207}
208
209/// A file the model attached to its reply with [`CHAT_ATTACH_TOOL`], as the
210/// tool's successful `tool.completed` output reports it.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct ReplyAttachment {
213    /// Absolute, symlink-free path of the file the tool checked.
214    pub path: String,
215    /// The name to show the recipient.
216    pub name: String,
217    #[serde(default, skip_serializing_if = "String::is_empty")]
218    pub mime: String,
219    pub size: u64,
220    /// Text sent with the file, if any.
221    #[serde(default, skip_serializing_if = "String::is_empty")]
222    pub caption: String,
223}
224
225/// The attachment a successful [`CHAT_ATTACH_TOOL`] call reports: its output
226/// is `{"attached": {...}, ...}`. Anything else is `None`.
227pub fn reply_attachment(tool: &str, success: bool, output: &str) -> Option<ReplyAttachment> {
228    if tool != CHAT_ATTACH_TOOL || !success {
229        return None;
230    }
231    let value: Value = serde_json::from_str(output).ok()?;
232    serde_json::from_value(value.get("attached")?.clone()).ok()
233}
234
235/// Why the server started a turn on its own.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub struct TurnOrigin {
238    /// `background`: finished background delegations are being reported.
239    pub kind: String,
240    /// The background jobs this turn reports.
241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
242    pub jobs: Vec<String>,
243}
244
245/// `TurnOrigin::kind` of a turn reporting finished background delegations.
246pub const ORIGIN_BACKGROUND: &str = "background";
247
248/// Background delegation jobs a `tool.completed` output shows starting or
249/// settling. `agent_*` calls with `background: true` return
250/// `{"job", "status":"running", "background":true}`; `agent_wait` and
251/// `agent_status` return job objects (or `{"jobs":[...]}`) whose status is no
252/// longer `running` once they finish. Clients use this to keep a session open
253/// while its jobs run, so the jobs are not cancelled with it.
254#[derive(Debug, Default, Clone, PartialEq, Eq)]
255pub struct BackgroundJobUpdate {
256    pub started: Vec<String>,
257    pub settled: Vec<String>,
258}
259
260pub fn background_job_update(output: &str) -> BackgroundJobUpdate {
261    let mut update = BackgroundJobUpdate::default();
262    let Ok(serde_json::Value::Object(value)) = serde_json::from_str::<serde_json::Value>(output)
263    else {
264        return update;
265    };
266    let mut visit = |job: &serde_json::Map<String, serde_json::Value>| {
267        let (Some(id), Some(status)) = (
268            job.get("job").and_then(serde_json::Value::as_str),
269            job.get("status").and_then(serde_json::Value::as_str),
270        ) else {
271            return;
272        };
273        if status == "running" {
274            if job.get("background").and_then(serde_json::Value::as_bool) == Some(true) {
275                update.started.push(id.to_owned());
276            }
277        } else {
278            update.settled.push(id.to_owned());
279        }
280    };
281    visit(&value);
282    for job in value
283        .get("jobs")
284        .and_then(serde_json::Value::as_array)
285        .into_iter()
286        .flatten()
287    {
288        if let serde_json::Value::Object(job) = job {
289            visit(job);
290        }
291    }
292    update
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296pub struct PeerInfo {
297    pub name: String,
298    pub version: String,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
302pub struct Usage {
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub input_tokens: Option<u64>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub output_tokens: Option<u64>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
310#[serde(tag = "type")]
311pub enum ClientMessage {
312    #[serde(rename = "daemon.control")]
313    DaemonControl {
314        request_id: String,
315        command: DaemonCommand,
316    },
317    #[serde(rename = "initialize")]
318    Initialize {
319        request_id: String,
320        protocol_version: u32,
321        client: PeerInfo,
322    },
323    #[serde(rename = "session.start")]
324    SessionStart {
325        request_id: String,
326        cwd: String,
327        #[serde(default, skip_serializing_if = "Option::is_none")]
328        provider: Option<String>,
329        #[serde(default, skip_serializing_if = "Option::is_none")]
330        model: Option<String>,
331        #[serde(default, skip_serializing_if = "Option::is_none")]
332        base_url: Option<String>,
333        #[serde(default, skip_serializing_if = "Option::is_none")]
334        no_tools: Option<bool>,
335        /// Delegation depth of the client, when it is itself a delegated
336        /// agent (such as a nested SCV). Tools started from the session count
337        /// from it, so the depth limit holds across processes.
338        #[serde(default, skip_serializing_if = "Option::is_none")]
339        delegation_depth: Option<u32>,
340        /// The chat channel this session answers on, as its users name it
341        /// (such as `WeChat` or `Feishu`). The user reads short plain-text
342        /// replies there and never sees tool calls, so the server tells the
343        /// model; a chat client also delivers files the model attaches to
344        /// its reply, so a session with tools offers [`CHAT_ATTACH_TOOL`].
345        /// At most [`MAX_CHANNEL_NAME_BYTES`], without control characters.
346        #[serde(default, skip_serializing_if = "Option::is_none")]
347        channel: Option<String>,
348        /// The client approves every approval request of this session
349        /// without asking anyone. Background jobs, which outlive the turn
350        /// that could carry their requests, then get the same answer;
351        /// otherwise they get only what the approval policy grants unasked.
352        #[serde(default, skip_serializing_if = "Option::is_none")]
353        auto_approve: Option<bool>,
354    },
355    #[serde(rename = "session.attach")]
356    SessionAttach {
357        request_id: String,
358        session_id: String,
359        cwd: String,
360    },
361    #[serde(rename = "turn.start")]
362    TurnStart {
363        request_id: String,
364        session_id: String,
365        prompt: String,
366        /// Files that come with the prompt, at most [`MAX_TURN_ATTACHMENTS`].
367        /// The server lists them for the model and shows it images directly
368        /// when the model accepts image input.
369        #[serde(default, skip_serializing_if = "Vec::is_empty")]
370        attachments: Vec<Attachment>,
371    },
372    #[serde(rename = "queue.update")]
373    QueueUpdate {
374        request_id: String,
375        session_id: String,
376        queue_id: String,
377        revision: u64,
378        prompt: String,
379    },
380    #[serde(rename = "queue.move")]
381    QueueMove {
382        request_id: String,
383        session_id: String,
384        queue_id: String,
385        revision: u64,
386        before_queue_id: Option<String>,
387    },
388    #[serde(rename = "queue.remove")]
389    QueueRemove {
390        request_id: String,
391        session_id: String,
392        queue_id: String,
393        revision: u64,
394    },
395    #[serde(rename = "session.pause")]
396    SessionPause {
397        request_id: String,
398        session_id: String,
399        paused: bool,
400    },
401    #[serde(rename = "turn.cancel")]
402    TurnCancel {
403        request_id: String,
404        session_id: String,
405        turn_id: String,
406    },
407    #[serde(rename = "approval.resolve")]
408    ApprovalResolve {
409        request_id: String,
410        session_id: String,
411        approval_id: String,
412        approved: bool,
413    },
414    #[serde(rename = "session.clear")]
415    SessionClear {
416        request_id: String,
417        session_id: String,
418    },
419}
420
421impl ServerEvent {
422    /// The submitting request of a turn-scoped event, which identifies the
423    /// turn to a client that has several turns' events interleaved.
424    pub fn turn_request_id(&self) -> Option<&str> {
425        match self {
426            Self::QueueDequeued { request_id, .. }
427            | Self::TurnStarted { request_id, .. }
428            | Self::AssistantDelta { request_id, .. }
429            | Self::AssistantCompleted { request_id, .. }
430            | Self::ToolProposed { request_id, .. }
431            | Self::ApprovalRequested { request_id, .. }
432            | Self::ToolStarted { request_id, .. }
433            | Self::ToolProgress { request_id, .. }
434            | Self::ToolCompleted { request_id, .. }
435            | Self::ContextCompacted { request_id, .. }
436            | Self::TurnCompleted { request_id, .. }
437            | Self::TurnCancelled { request_id, .. }
438            | Self::TurnFailed { request_id, .. } => Some(request_id),
439            _ => None,
440        }
441    }
442}
443
444impl ClientMessage {
445    pub fn request_id(&self) -> &str {
446        match self {
447            Self::Initialize { request_id, .. }
448            | Self::DaemonControl { request_id, .. }
449            | Self::SessionStart { request_id, .. }
450            | Self::SessionAttach { request_id, .. }
451            | Self::TurnStart { request_id, .. }
452            | Self::QueueUpdate { request_id, .. }
453            | Self::QueueMove { request_id, .. }
454            | Self::QueueRemove { request_id, .. }
455            | Self::SessionPause { request_id, .. }
456            | Self::TurnCancel { request_id, .. }
457            | Self::ApprovalResolve { request_id, .. }
458            | Self::SessionClear { request_id, .. } => request_id,
459        }
460    }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
464#[serde(tag = "type")]
465pub enum ServerEvent {
466    #[serde(rename = "daemon.status")]
467    DaemonStatus {
468        request_id: String,
469        status: DaemonStatus,
470    },
471    #[serde(rename = "initialized")]
472    Initialized {
473        request_id: String,
474        protocol_version: u32,
475        server: PeerInfo,
476    },
477    #[serde(rename = "session.started")]
478    SessionStarted {
479        request_id: String,
480        session_id: String,
481        cwd: String,
482        model: String,
483        context_max_tokens: usize,
484        max_server_frame_bytes: usize,
485        max_transcript_bytes: usize,
486        max_transcript_items: usize,
487        max_prompt_history_bytes: usize,
488        max_prompt_history_items: usize,
489    },
490    #[serde(rename = "queue.snapshot")]
491    QueueSnapshot {
492        request_id: Option<String>,
493        session_id: String,
494        seq: u64,
495        entries: Vec<QueueEntry>,
496        paused: bool,
497    },
498    #[serde(rename = "queue.enqueued")]
499    QueueEnqueued {
500        request_id: String,
501        session_id: String,
502        seq: u64,
503        entry: QueueEntry,
504        position: usize,
505    },
506    #[serde(rename = "queue.updated")]
507    QueueUpdated {
508        request_id: String,
509        session_id: String,
510        seq: u64,
511        entry: QueueEntry,
512    },
513    #[serde(rename = "queue.moved")]
514    QueueMoved {
515        request_id: String,
516        session_id: String,
517        seq: u64,
518        queue_id: String,
519        position: usize,
520        revision: u64,
521    },
522    #[serde(rename = "queue.removed")]
523    QueueRemoved {
524        request_id: String,
525        session_id: String,
526        seq: u64,
527        queue_id: String,
528        revision: u64,
529    },
530    #[serde(rename = "queue.dequeued")]
531    QueueDequeued {
532        request_id: String,
533        session_id: String,
534        seq: u64,
535        queue_id: String,
536        turn_id: String,
537    },
538    #[serde(rename = "session.paused")]
539    SessionPaused {
540        request_id: String,
541        session_id: String,
542        seq: u64,
543        paused: bool,
544    },
545    #[serde(rename = "turn.started")]
546    TurnStarted {
547        request_id: String,
548        session_id: String,
549        turn_id: String,
550        seq: u64,
551        /// Set when the server started this turn itself, such as to report
552        /// finished background work; absent for a client's own `turn.start`.
553        #[serde(default, skip_serializing_if = "Option::is_none")]
554        origin: Option<TurnOrigin>,
555    },
556    #[serde(rename = "assistant.delta")]
557    AssistantDelta {
558        request_id: String,
559        session_id: String,
560        turn_id: String,
561        seq: u64,
562        content: String,
563    },
564    #[serde(rename = "assistant.completed")]
565    AssistantCompleted {
566        request_id: String,
567        session_id: String,
568        turn_id: String,
569        seq: u64,
570        content: String,
571    },
572    #[serde(rename = "tool.proposed")]
573    ToolProposed {
574        request_id: String,
575        session_id: String,
576        turn_id: String,
577        seq: u64,
578        call_id: String,
579        name: String,
580        arguments: Value,
581    },
582    #[serde(rename = "approval.requested")]
583    ApprovalRequested {
584        request_id: String,
585        session_id: String,
586        turn_id: String,
587        seq: u64,
588        approval_id: String,
589        call_id: String,
590        name: String,
591        risk: String,
592        cwd: String,
593        summary: String,
594    },
595    #[serde(rename = "tool.started")]
596    ToolStarted {
597        request_id: String,
598        session_id: String,
599        turn_id: String,
600        seq: u64,
601        call_id: String,
602        name: String,
603    },
604    /// Short status lines from a running tool, at most two events a second
605    /// per call and 512 bytes each. Display only; not part of the history.
606    #[serde(rename = "tool.progress")]
607    ToolProgress {
608        request_id: String,
609        session_id: String,
610        turn_id: String,
611        seq: u64,
612        call_id: String,
613        text: String,
614    },
615    #[serde(rename = "tool.completed")]
616    ToolCompleted {
617        request_id: String,
618        session_id: String,
619        turn_id: String,
620        seq: u64,
621        call_id: String,
622        name: String,
623        success: bool,
624        output: String,
625        truncated: bool,
626    },
627    #[serde(rename = "context.compacted")]
628    ContextCompacted {
629        request_id: String,
630        session_id: String,
631        turn_id: String,
632        seq: u64,
633        before_tokens: usize,
634        after_tokens: usize,
635        removed_messages: usize,
636    },
637    #[serde(rename = "session.trimmed")]
638    SessionTrimmed {
639        request_id: String,
640        session_id: String,
641        seq: u64,
642        removed_messages: usize,
643        history_bytes: usize,
644    },
645    #[serde(rename = "session.cleared")]
646    SessionCleared {
647        request_id: String,
648        session_id: String,
649        seq: u64,
650    },
651    #[serde(rename = "turn.completed")]
652    TurnCompleted {
653        request_id: String,
654        session_id: String,
655        turn_id: String,
656        seq: u64,
657        steps: usize,
658        usage: Usage,
659        /// Set when the server started this turn itself, such as to report
660        /// finished background work; absent for a client's own `turn.start`.
661        #[serde(default, skip_serializing_if = "Option::is_none")]
662        origin: Option<TurnOrigin>,
663    },
664    #[serde(rename = "turn.cancelled")]
665    TurnCancelled {
666        request_id: String,
667        session_id: String,
668        turn_id: String,
669        seq: u64,
670        /// Set when the server started this turn itself, such as to report
671        /// finished background work; absent for a client's own `turn.start`.
672        #[serde(default, skip_serializing_if = "Option::is_none")]
673        origin: Option<TurnOrigin>,
674    },
675    #[serde(rename = "turn.failed")]
676    TurnFailed {
677        request_id: String,
678        session_id: String,
679        turn_id: String,
680        seq: u64,
681        code: String,
682        message: String,
683        /// Set when the server started this turn itself, such as to report
684        /// finished background work; absent for a client's own `turn.start`.
685        #[serde(default, skip_serializing_if = "Option::is_none")]
686        origin: Option<TurnOrigin>,
687    },
688    #[serde(rename = "error")]
689    Error {
690        #[serde(skip_serializing_if = "Option::is_none")]
691        request_id: Option<String>,
692        code: String,
693        message: String,
694        fatal: bool,
695    },
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn client_message_round_trip() {
704        let message = ClientMessage::TurnStart {
705            request_id: "3".into(),
706            session_id: "session".into(),
707            prompt: "hello".into(),
708            attachments: Vec::new(),
709        };
710        let json = serde_json::to_string(&message).unwrap();
711        assert!(json.contains("\"type\":\"turn.start\""));
712        assert!(!json.contains("attachments"), "{json}");
713        assert_eq!(
714            serde_json::from_str::<ClientMessage>(&json).unwrap(),
715            message
716        );
717    }
718
719    #[test]
720    fn turns_carry_attachments_and_older_frames_have_none() {
721        let message = ClientMessage::TurnStart {
722            request_id: "3".into(),
723            session_id: "session".into(),
724            prompt: "what is this?".into(),
725            attachments: vec![Attachment {
726                kind: "image".into(),
727                path: "/media/photo.jpg".into(),
728                name: "photo.jpg".into(),
729                mime: "image/jpeg".into(),
730                size: 1234,
731                transcript: None,
732            }],
733        };
734        let wire = serde_json::to_string(&message).unwrap();
735        assert!(wire.contains(r#""kind":"image""#), "{wire}");
736        assert!(!wire.contains("transcript"), "{wire}");
737        assert_eq!(
738            serde_json::from_str::<ClientMessage>(&wire).unwrap(),
739            message
740        );
741        let older = r#"{"type":"turn.start","request_id":"1","session_id":"s","prompt":"hi"}"#;
742        assert!(matches!(
743            serde_json::from_str::<ClientMessage>(older).unwrap(),
744            ClientMessage::TurnStart { attachments, .. } if attachments.is_empty()
745        ));
746    }
747
748    #[test]
749    fn only_a_successful_chat_attach_reports_an_attachment() {
750        let output = r#"{"attached":{"path":"/w/report.pdf","name":"report.pdf","mime":"application/pdf","size":10},"note":"sent after your reply"}"#;
751        let attached = reply_attachment(CHAT_ATTACH_TOOL, true, output).unwrap();
752        assert_eq!(attached.name, "report.pdf");
753        assert_eq!(attached.size, 10);
754        assert!(attached.caption.is_empty());
755        assert_eq!(reply_attachment(CHAT_ATTACH_TOOL, false, output), None);
756        assert_eq!(reply_attachment("bash", true, output), None);
757        assert_eq!(reply_attachment(CHAT_ATTACH_TOOL, true, "not json"), None);
758    }
759
760    #[test]
761    fn tool_progress_and_delegation_depth_round_trip() {
762        let event = ServerEvent::ToolProgress {
763            request_id: "r".into(),
764            session_id: "s".into(),
765            turn_id: "t".into(),
766            seq: 4,
767            call_id: "c".into(),
768            text: "$ cargo test\nupdate …/src/lib.rs".into(),
769        };
770        let wire = serde_json::to_string(&event).unwrap();
771        assert!(wire.contains(r#""type":"tool.progress""#));
772        assert_eq!(serde_json::from_str::<ServerEvent>(&wire).unwrap(), event);
773
774        let start = |depth| ClientMessage::SessionStart {
775            request_id: "1".into(),
776            cwd: "/w".into(),
777            provider: None,
778            model: None,
779            base_url: None,
780            no_tools: None,
781            delegation_depth: depth,
782            channel: None,
783            auto_approve: None,
784        };
785        let nested = serde_json::to_string(&start(Some(2))).unwrap();
786        assert!(nested.contains(r#""delegation_depth":2"#));
787        assert_eq!(
788            serde_json::from_str::<ClientMessage>(&nested).unwrap(),
789            start(Some(2))
790        );
791        // Omitted when unset, and optional on the wire.
792        let direct = serde_json::to_string(&start(None)).unwrap();
793        assert!(!direct.contains("delegation_depth"));
794        let older = r#"{"type":"session.start","request_id":"1","cwd":"/w"}"#;
795        assert_eq!(
796            serde_json::from_str::<ClientMessage>(older).unwrap(),
797            start(None)
798        );
799        assert_eq!(PROTOCOL_VERSION, 3);
800    }
801
802    #[test]
803    fn chat_sessions_name_their_channel_and_approval_mode() {
804        let chat = ClientMessage::SessionStart {
805            request_id: "1".into(),
806            cwd: "/w".into(),
807            provider: None,
808            model: None,
809            base_url: None,
810            no_tools: Some(false),
811            delegation_depth: None,
812            channel: Some("WeChat".into()),
813            auto_approve: Some(true),
814        };
815        let wire = serde_json::to_string(&chat).unwrap();
816        assert!(wire.contains(r#""channel":"WeChat""#), "{wire}");
817        assert!(wire.contains(r#""auto_approve":true"#), "{wire}");
818        assert_eq!(serde_json::from_str::<ClientMessage>(&wire).unwrap(), chat);
819        // Frames from older clients omit both.
820        let older = r#"{"type":"session.start","request_id":"1","cwd":"/w"}"#;
821        assert!(matches!(
822            serde_json::from_str::<ClientMessage>(older).unwrap(),
823            ClientMessage::SessionStart {
824                channel: None,
825                auto_approve: None,
826                ..
827            }
828        ));
829    }
830
831    #[test]
832    fn restart_requests_and_scheduled_restarts_round_trip() {
833        let request = DaemonCommand::RestartWhenIdle {
834            version: Some("0.1.37".into()),
835            commit: Some("abc1234".into()),
836            parent: Some("0a1b2c3d/session/codex-3f9a2c".into()),
837            max_wait_seconds: Some(600),
838        };
839        let wire = serde_json::to_string(&request).unwrap();
840        assert!(wire.contains(r#""action":"restart_when_idle""#), "{wire}");
841        assert_eq!(
842            serde_json::from_str::<DaemonCommand>(&wire).unwrap(),
843            request
844        );
845        assert_eq!(
846            serde_json::from_str::<DaemonCommand>(r#"{"action":"restart_when_idle"}"#).unwrap(),
847            DaemonCommand::RestartWhenIdle {
848                version: None,
849                commit: None,
850                parent: None,
851                max_wait_seconds: None,
852            }
853        );
854        // Status from a daemon without a scheduled restart omits it, and
855        // older status frames parse.
856        let status: DaemonStatus =
857            serde_json::from_str(r#"{"version":"0.1.0","pid":1,"components":[]}"#).unwrap();
858        assert!(status.restart.is_none());
859        assert!(!serde_json::to_string(&status).unwrap().contains("restart"));
860    }
861
862    #[test]
863    fn additive_fields_are_ignored() {
864        let json = r#"{"type":"session.clear","request_id":"1","session_id":"s","future":true}"#;
865        assert!(matches!(
866            serde_json::from_str::<ClientMessage>(json).unwrap(),
867            ClientMessage::SessionClear { .. }
868        ));
869    }
870
871    #[test]
872    fn event_round_trip() {
873        let event = ServerEvent::AssistantDelta {
874            request_id: "1".into(),
875            session_id: "s".into(),
876            turn_id: "t".into(),
877            seq: 4,
878            content: "hello".into(),
879        };
880        let encoded = serde_json::to_string(&event).unwrap();
881        assert_eq!(
882            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
883            event
884        );
885    }
886
887    #[test]
888    fn queue_messages_and_events_round_trip() {
889        let message = ClientMessage::QueueMove {
890            request_id: "q1".into(),
891            session_id: "s".into(),
892            queue_id: "q".into(),
893            revision: 2,
894            before_queue_id: None,
895        };
896        let encoded = serde_json::to_string(&message).unwrap();
897        assert_eq!(
898            serde_json::from_str::<ClientMessage>(&encoded).unwrap(),
899            message
900        );
901        let event = ServerEvent::QueueSnapshot {
902            request_id: None,
903            session_id: "s".into(),
904            seq: 4,
905            entries: vec![QueueEntry {
906                queue_id: "q".into(),
907                revision: 1,
908                prompt: "hello".into(),
909                submitter: "cli".into(),
910                attachments: Vec::new(),
911            }],
912            paused: false,
913        };
914        let encoded = serde_json::to_string(&event).unwrap();
915        assert_eq!(
916            serde_json::from_str::<ServerEvent>(&encoded).unwrap(),
917            event
918        );
919    }
920
921    #[test]
922    fn remote_tools_fields_are_additive() {
923        let legacy: DaemonCommand = serde_json::from_str(
924            r#"{"action":"channel_set","channel":"wechat","account":"a","enabled":true,"workspace":null}"#,
925        )
926        .unwrap();
927        assert!(matches!(
928            legacy,
929            DaemonCommand::ChannelSet {
930                remote_tools: None,
931                ..
932            }
933        ));
934        let owner = DaemonCommand::ChannelSet {
935            channel: "wechat".into(),
936            account: "a".into(),
937            enabled: true,
938            workspace: None,
939            remote_tools: Some(RemoteTools::Owner),
940        };
941        let encoded = serde_json::to_string(&owner).unwrap();
942        assert!(encoded.contains(r#""remote_tools":"owner""#));
943        assert_eq!(
944            serde_json::from_str::<DaemonCommand>(&encoded).unwrap(),
945            owner
946        );
947        let health: ComponentHealth = serde_json::from_str(
948            r#"{"id":"clawbot:a","account":"a","bot_id":null,"user_id":null,"enabled":true,"state":"connected","last_success_unix_seconds":null,"error":null,"restarts":0}"#,
949        )
950        .unwrap();
951        assert_eq!(health.remote_tools, RemoteTools::None);
952        // Daemons before channels reported no channel.
953        assert!(health.channel.is_empty());
954    }
955
956    #[test]
957    fn delegation_control_round_trips_and_older_status_still_parses() {
958        for (command, wire) in [
959            (
960                DaemonCommand::Delegations { all: true },
961                r#"{"action":"delegations","all":true}"#,
962            ),
963            (
964                DaemonCommand::DelegationKill {
965                    handle: Some("codex-3f9a2c".into()),
966                    orphans: false,
967                },
968                r#"{"action":"delegation_kill","handle":"codex-3f9a2c","orphans":false}"#,
969            ),
970        ] {
971            assert_eq!(serde_json::to_string(&command).unwrap(), wire);
972            assert_eq!(
973                serde_json::from_str::<DaemonCommand>(wire).unwrap(),
974                command
975            );
976        }
977        assert_eq!(
978            serde_json::from_str::<DaemonCommand>(r#"{"action":"delegation_kill","orphans":true}"#)
979                .unwrap(),
980            DaemonCommand::DelegationKill {
981                handle: None,
982                orphans: true
983            }
984        );
985        // A status from a daemon without delegation tracking.
986        let status: DaemonStatus =
987            serde_json::from_str(r#"{"version":"0.1.23","pid":7,"components":[]}"#).unwrap();
988        assert_eq!(status.delegations, DelegationSummary::default());
989    }
990
991    #[test]
992    fn server_started_turns_carry_their_origin_and_client_turns_omit_it() {
993        let started = ServerEvent::TurnStarted {
994            request_id: "background:1".into(),
995            session_id: "s".into(),
996            turn_id: "t".into(),
997            seq: 4,
998            origin: Some(TurnOrigin {
999                kind: ORIGIN_BACKGROUND.into(),
1000                jobs: vec!["job-1".into()],
1001            }),
1002        };
1003        let json = serde_json::to_value(&started).unwrap();
1004        assert_eq!(
1005            json["origin"],
1006            serde_json::json!({"kind":"background","jobs":["job-1"]})
1007        );
1008        assert_eq!(
1009            serde_json::from_value::<ServerEvent>(json).unwrap(),
1010            started
1011        );
1012        assert_eq!(started.turn_request_id(), Some("background:1"));
1013        // A client's own turn has no origin on the wire, and older frames parse.
1014        let own: ServerEvent = serde_json::from_str(
1015            r#"{"type":"turn.completed","request_id":"r","session_id":"s","turn_id":"t","seq":9,"steps":1,"usage":{}}"#,
1016        )
1017        .unwrap();
1018        assert!(matches!(
1019            own,
1020            ServerEvent::TurnCompleted { origin: None, .. }
1021        ));
1022        assert!(!serde_json::to_string(&own).unwrap().contains("origin"));
1023    }
1024
1025    #[test]
1026    fn background_job_updates_come_from_start_wait_and_status_outputs() {
1027        let started = background_job_update(
1028            r#"{"job":"job-1","tool":"agent_codex","status":"running","background":true}"#,
1029        );
1030        assert_eq!(started.started, vec!["job-1".to_owned()]);
1031        assert!(started.settled.is_empty());
1032        // A running job listed by agent_status is neither started nor settled.
1033        let listed = background_job_update(
1034            r#"{"jobs":[{"job":"job-1","status":"running"},{"job":"job-2","status":"failed"}]}"#,
1035        );
1036        assert!(listed.started.is_empty());
1037        assert_eq!(listed.settled, vec!["job-2".to_owned()]);
1038        let waited = background_job_update(r#"{"job":"job-1","status":"completed"}"#);
1039        assert_eq!(waited.settled, vec!["job-1".to_owned()]);
1040        // Ordinary agent results and non-JSON output are no job updates.
1041        for other in [
1042            r#"{"agent":"codex","status":"completed"}"#,
1043            "plain text",
1044            "[1]",
1045        ] {
1046            assert_eq!(background_job_update(other), BackgroundJobUpdate::default());
1047        }
1048    }
1049}