1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const PROTOCOL_VERSION: u32 = 3;
7
8pub const MAX_CHANNEL_NAME_BYTES: usize = 32;
10pub const MAX_TURN_ATTACHMENTS: usize = 16;
12pub 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 #[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 #[serde(default)]
44 pub remote_tools: RemoteTools,
45}
46
47#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum RemoteTools {
51 #[default]
53 None,
54 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 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub restart: Option<RestartInfo>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct RestartInfo {
73 pub to_version: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub waiting_for: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub requester: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub origin: Option<String>,
85 pub deadline_unix_seconds: u64,
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
91pub struct DelegationSummary {
92 pub active: u64,
94 pub reaped: u64,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub entries: Vec<DelegationInfo>,
99 #[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 pub owner_pid: u32,
113 pub processes: u32,
115 pub cwd: String,
116 pub started_unix_seconds: u64,
117 pub orphaned: bool,
119 #[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 ChannelSet {
134 channel: String,
135 account: String,
136 enabled: bool,
137 workspace: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 remote_tools: Option<RemoteTools>,
141 },
142 ChannelLogout {
144 channel: String,
145 account: String,
146 },
147 Delegations {
149 #[serde(default)]
150 all: bool,
151 },
152 DelegationKill {
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 handle: Option<String>,
156 #[serde(default)]
157 orphans: bool,
158 },
159 RestartWhenIdle {
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 version: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 commit: Option<String>,
169 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub attachments: Vec<Attachment>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct Attachment {
192 pub kind: String,
194 pub path: String,
196 #[serde(default, skip_serializing_if = "String::is_empty")]
198 pub name: String,
199 #[serde(default, skip_serializing_if = "String::is_empty")]
201 pub mime: String,
202 pub size: u64,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub transcript: Option<String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct ReplyAttachment {
213 pub path: String,
215 pub name: String,
217 #[serde(default, skip_serializing_if = "String::is_empty")]
218 pub mime: String,
219 pub size: u64,
220 #[serde(default, skip_serializing_if = "String::is_empty")]
222 pub caption: String,
223}
224
225pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub struct TurnOrigin {
238 pub kind: String,
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
242 pub jobs: Vec<String>,
243}
244
245pub const ORIGIN_BACKGROUND: &str = "background";
247
248#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
339 delegation_depth: Option<u32>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
347 channel: Option<String>,
348 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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}