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