1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const PROTOCOL_VERSION: u32 = 3;
7
8pub 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 #[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 #[serde(default)]
40 pub remote_tools: RemoteTools,
41}
42
43#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum RemoteTools {
47 #[default]
49 None,
50 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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
65pub struct DelegationSummary {
66 pub active: u64,
68 pub reaped: u64,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub entries: Vec<DelegationInfo>,
73 #[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 pub owner_pid: u32,
87 pub processes: u32,
89 pub cwd: String,
90 pub started_unix_seconds: u64,
91 pub orphaned: bool,
93 #[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 ChannelSet {
108 channel: String,
109 account: String,
110 enabled: bool,
111 workspace: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 remote_tools: Option<RemoteTools>,
115 },
116 ChannelLogout {
118 channel: String,
119 account: String,
120 },
121 Delegations {
123 #[serde(default)]
124 all: bool,
125 },
126 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct TurnOrigin {
146 pub kind: String,
148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub jobs: Vec<String>,
151}
152
153pub const ORIGIN_BACKGROUND: &str = "background";
155
156#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
247 delegation_depth: Option<u32>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
254 channel: Option<String>,
255 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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}