1use std::sync::Arc;
7use std::time::Instant;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11use tracing::{debug, warn};
12
13use crate::a2a::A2ARouter;
14use crate::cron::CronService;
15use crate::ipc::{KernelMessage, MessagePayload, MessageTarget};
16use crate::process::{Pid, ProcessState, ProcessTable, ResourceUsage};
17
18#[allow(clippy::too_many_arguments)]
31pub async fn kernel_agent_loop(
32 pid: Pid,
33 cancel: CancellationToken,
34 mut inbox: mpsc::Receiver<KernelMessage>,
35 a2a: Arc<A2ARouter>,
36 cron: Arc<CronService>,
37 process_table: Arc<ProcessTable>,
38 tool_registry: Option<Arc<crate::wasm_runner::ToolRegistry>>,
39 #[cfg(feature = "exochain")] chain: Option<Arc<crate::chain::ChainManager>>,
40 #[cfg(feature = "exochain")] gate: Option<Arc<dyn crate::gate::GateBackend>>,
41) -> i32 {
42 let started = Instant::now();
43 debug!(pid, "agent loop started");
44
45 #[cfg(feature = "exochain")]
47 let agent_id = process_table
48 .get(pid)
49 .map(|e| e.agent_id.clone())
50 .unwrap_or_else(|| format!("pid-{pid}"));
51
52 let mut usage = ResourceUsage::default();
53
54 loop {
55 tokio::select! {
56 _ = cancel.cancelled() => {
57 debug!(pid, "agent loop cancelled");
58 usage.cpu_time_ms = started.elapsed().as_millis() as u64;
60 let _ = process_table.update_resources(pid, usage);
61 return 0;
62 }
63 msg = inbox.recv() => {
64 match msg {
65 Some(message) => {
66 let cmd = extract_cmd(&message);
67
68 #[cfg(feature = "exochain")]
70 if let Some(ref cm) = chain {
71 cm.append(
72 "ipc",
73 "ipc.recv",
74 Some(serde_json::json!({
75 "pid": pid,
76 "from": message.from,
77 "msg_id": &message.id,
78 "cmd": cmd.as_deref().unwrap_or("none"),
79 })),
80 );
81 }
82
83 if cmd.as_deref() == Some("suspend") {
85 let reply = KernelMessage::with_correlation(
88 pid,
89 MessageTarget::Process(message.from),
90 MessagePayload::Json(serde_json::json!({
91 "status": "suspended",
92 "pid": pid,
93 })),
94 message.id.clone(),
95 );
96 send_reply(&a2a, reply, #[cfg(feature = "exochain")] chain.as_deref()).await;
97 usage.messages_sent += 1;
98
99 let _ = process_table.update_state(pid, ProcessState::Suspended);
101 debug!(pid, "agent suspended");
102
103 #[cfg(feature = "exochain")]
104 if let Some(ref cm) = chain {
105 cm.append(
106 "supervisor",
107 "agent.suspend",
108 Some(serde_json::json!({
109 "pid": pid,
110 "from": message.from,
111 "msg_id": &message.id,
112 })),
113 );
114 }
115
116 let resumed = parking_loop(
118 pid,
119 &cancel,
120 &mut inbox,
121 &a2a,
122 &process_table,
123 #[cfg(feature = "exochain")] chain.as_deref(),
124 &mut usage,
125 ).await;
126
127 if !resumed {
128 usage.cpu_time_ms = started.elapsed().as_millis() as u64;
130 let _ = process_table.update_resources(pid, usage);
131 return 0;
132 }
133 continue;
135 }
136
137 #[cfg(feature = "exochain")]
139 if let Some(ref gate_backend) = gate
140 && let Some(ref cmd_str) = cmd
141 {
142 let action = match cmd_str.as_str() {
143 "exec" => Some("tool.exec"),
144 "cron.add" => Some("service.cron.add"),
145 "cron.remove" => Some("service.cron.remove"),
146 _ => None,
147 };
148 if let Some(action_str) = action {
149 let context = if cmd_str == "exec" {
151 let tool_name = extract_tool_name(&message);
152 let effect = tool_name.as_deref().and_then(|tn| {
153 tool_registry.as_ref().and_then(|reg| {
154 reg.get(tn).map(|t| &t.spec().effect)
155 })
156 });
157 let mut ctx = serde_json::json!({"pid": pid});
158 if let Some(tn) = &tool_name {
159 ctx["tool"] = serde_json::json!(tn);
160 }
161 if let Some(ev) = effect {
162 ctx["effect"] = serde_json::json!({
163 "risk": ev.risk,
164 "security": ev.security,
165 "privacy": ev.privacy,
166 });
167 }
168 ctx
169 } else {
170 serde_json::json!({"pid": pid})
171 };
172 let decision = gate_backend.check(&agent_id, action_str, &context);
173 match decision {
174 crate::gate::GateDecision::Deny { reason, .. } => {
175 let reply = KernelMessage::with_correlation(
176 pid,
177 MessageTarget::Process(message.from),
178 MessagePayload::Json(serde_json::json!({
179 "error": reason,
180 "denied": true,
181 })),
182 message.id.clone(),
183 );
184 send_reply(&a2a, reply, chain.as_deref()).await;
185 usage.messages_sent += 1;
186 continue;
187 }
188 crate::gate::GateDecision::Defer { reason } => {
189 let reply = KernelMessage::with_correlation(
190 pid,
191 MessageTarget::Process(message.from),
192 MessagePayload::Json(serde_json::json!({
193 "deferred": true,
194 "reason": reason,
195 })),
196 message.id.clone(),
197 );
198 send_reply(&a2a, reply, chain.as_deref()).await;
199 usage.messages_sent += 1;
200 continue;
201 }
202 crate::gate::GateDecision::Permit { .. } => {
203 }
205 }
206 }
207 }
208
209 if cmd.as_deref() == Some("exec") {
211 usage.tool_calls += 1;
212
213 #[cfg(feature = "exochain")]
215 {
216 let sudo_flag = match &message.payload {
217 MessagePayload::Json(v) => v.get("sudo").and_then(|s| s.as_bool()).unwrap_or(false),
218 _ => false,
219 };
220 if sudo_flag
221 && let Some(ref cm) = chain
222 {
223 cm.append(
224 "security",
225 "sudo.override",
226 Some(serde_json::json!({
227 "pid": pid,
228 "agent_id": &agent_id,
229 "tool": extract_tool_name(&message).unwrap_or_default(),
230 })),
231 );
232 }
233 }
234 }
235
236 handle_message(
237 pid,
238 &message,
239 &a2a,
240 &cron,
241 tool_registry.as_deref(),
242 #[cfg(feature = "exochain")]
243 chain.as_deref(),
244 &started,
245 ).await;
246
247 usage.messages_sent += 1;
248
249 #[cfg(feature = "exochain")]
251 if let Some(ref cm) = chain {
252 cm.append(
253 "ipc",
254 "ipc.ack",
255 Some(serde_json::json!({
256 "pid": pid,
257 "msg_id": &message.id,
258 "cmd": cmd.as_deref().unwrap_or("none"),
259 "status": "processed",
260 })),
261 );
262 }
263
264 if usage.messages_sent % 10 == 0 {
266 usage.cpu_time_ms = started.elapsed().as_millis() as u64;
267 let _ = process_table.update_resources(pid, usage.clone());
268 }
269 }
270 None => {
271 debug!(pid, "inbox closed, exiting");
273 usage.cpu_time_ms = started.elapsed().as_millis() as u64;
274 let _ = process_table.update_resources(pid, usage);
275 return 0;
276 }
277 }
278 }
279 }
280 }
281}
282
283fn extract_cmd(msg: &KernelMessage) -> Option<String> {
285 match &msg.payload {
286 MessagePayload::Json(v) => v.get("cmd").and_then(|c| c.as_str()).map(String::from),
287 MessagePayload::Text(text) => {
288 if let Ok(v) = serde_json::from_str::<serde_json::Value>(text) {
289 v.get("cmd").and_then(|c| c.as_str()).map(String::from)
290 } else {
291 None
292 }
293 }
294 _ => None,
295 }
296}
297
298#[cfg(feature = "exochain")]
300fn extract_tool_name(msg: &KernelMessage) -> Option<String> {
301 match &msg.payload {
302 MessagePayload::Json(v) => v.get("tool").and_then(|t| t.as_str()).map(String::from),
303 MessagePayload::Text(text) => serde_json::from_str::<serde_json::Value>(text)
304 .ok()
305 .and_then(|v| v.get("tool").and_then(|t| t.as_str()).map(String::from)),
306 _ => None,
307 }
308}
309
310async fn parking_loop(
315 pid: Pid,
316 cancel: &CancellationToken,
317 inbox: &mut mpsc::Receiver<KernelMessage>,
318 a2a: &A2ARouter,
319 process_table: &ProcessTable,
320 #[cfg(feature = "exochain")] chain: Option<&crate::chain::ChainManager>,
321 usage: &mut ResourceUsage,
322) -> bool {
323 loop {
324 tokio::select! {
325 _ = cancel.cancelled() => {
326 debug!(pid, "cancelled while suspended");
327 return false;
328 }
329 msg = inbox.recv() => {
330 match msg {
331 Some(message) => {
332 let cmd = extract_cmd(&message);
333 if cmd.as_deref() == Some("resume") {
334 let _ = process_table.update_state(pid, ProcessState::Running);
336 debug!(pid, "agent resumed");
337
338 #[cfg(feature = "exochain")]
339 if let Some(cm) = chain {
340 cm.append(
341 "supervisor",
342 "agent.resume",
343 Some(serde_json::json!({
344 "pid": pid,
345 "from": message.from,
346 "msg_id": &message.id,
347 })),
348 );
349 }
350
351 let reply = KernelMessage::with_correlation(
352 pid,
353 MessageTarget::Process(message.from),
354 MessagePayload::Json(serde_json::json!({
355 "status": "resumed",
356 "pid": pid,
357 })),
358 message.id.clone(),
359 );
360 send_reply(a2a, reply, #[cfg(feature = "exochain")] chain).await;
361 usage.messages_sent += 1;
362 return true;
363 }
364
365 let reply = KernelMessage::with_correlation(
367 pid,
368 MessageTarget::Process(message.from),
369 MessagePayload::Json(serde_json::json!({
370 "error": "agent suspended",
371 "pid": pid,
372 })),
373 message.id.clone(),
374 );
375 send_reply(a2a, reply, #[cfg(feature = "exochain")] chain).await;
376 usage.messages_sent += 1;
377 }
378 None => {
379 return false;
381 }
382 }
383 }
384 }
385 }
386}
387
388async fn send_reply(
390 a2a: &A2ARouter,
391 reply: KernelMessage,
392 #[cfg(feature = "exochain")] chain: Option<&crate::chain::ChainManager>,
393) {
394 #[cfg(feature = "exochain")]
395 {
396 if let Err(e) = a2a.send_checked(reply, chain).await {
397 warn!(error = %e, "failed to send reply");
398 }
399 }
400 #[cfg(not(feature = "exochain"))]
401 {
402 if let Err(e) = a2a.send(reply).await {
403 warn!(error = %e, "failed to send reply");
404 }
405 }
406}
407
408async fn handle_message(
410 pid: Pid,
411 msg: &KernelMessage,
412 a2a: &A2ARouter,
413 cron: &CronService,
414 tool_registry: Option<&crate::wasm_runner::ToolRegistry>,
415 #[cfg(feature = "exochain")] chain: Option<&crate::chain::ChainManager>,
416 started: &Instant,
417) {
418 let cmd_value = match &msg.payload {
420 MessagePayload::Json(v) => v.clone(),
421 MessagePayload::Text(text) => {
422 match serde_json::from_str::<serde_json::Value>(text) {
424 Ok(v) => v,
425 Err(_) => serde_json::json!({"cmd": "echo", "text": text}),
426 }
427 }
428 MessagePayload::Rvf { segment_type, data } => {
429 debug!(
433 pid,
434 segment_type,
435 data_len = data.len(),
436 "received RVF payload"
437 );
438
439 #[cfg(feature = "exochain")]
441 {
442 if let Ok(val) = ciborium::from_reader::<ciborium::Value, _>(&data[..]) {
443 let json_str = serde_json::to_string(&val).unwrap_or_default();
444 match serde_json::from_str::<serde_json::Value>(&json_str) {
445 Ok(v) => v,
446 Err(_) => serde_json::json!({
447 "cmd": "rvf.recv",
448 "segment_type": segment_type,
449 "data_len": data.len(),
450 }),
451 }
452 } else if let Ok(v) = serde_json::from_slice::<serde_json::Value>(data) {
453 v
454 } else {
455 serde_json::json!({
456 "cmd": "rvf.recv",
457 "segment_type": segment_type,
458 "data_len": data.len(),
459 })
460 }
461 }
462 #[cfg(not(feature = "exochain"))]
464 {
465 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(data) {
466 v
467 } else {
468 serde_json::json!({
469 "cmd": "rvf.recv",
470 "segment_type": segment_type,
471 "data_len": data.len(),
472 })
473 }
474 }
475 }
476 _ => {
477 debug!(pid, "ignoring signal message");
478 return;
479 }
480 };
481
482 let cmd = cmd_value
483 .get("cmd")
484 .and_then(|v| v.as_str())
485 .unwrap_or("unknown");
486
487 let response = match cmd {
488 "ping" => {
489 let uptime_ms = started.elapsed().as_millis() as u64;
490 serde_json::json!({
491 "status": "ok",
492 "pid": pid,
493 "uptime_ms": uptime_ms,
494 })
495 }
496 "cron.add" => {
497 let name = cmd_value
498 .get("name")
499 .and_then(|v| v.as_str())
500 .unwrap_or("unnamed")
501 .to_string();
502 let interval_secs = cmd_value
503 .get("interval_secs")
504 .and_then(|v| v.as_u64())
505 .unwrap_or(60);
506 let command = cmd_value
507 .get("command")
508 .and_then(|v| v.as_str())
509 .unwrap_or("ping")
510 .to_string();
511 let target_pid = cmd_value.get("target_pid").and_then(|v| v.as_u64());
512
513 match cron.add_job(name, interval_secs, command, target_pid) {
514 Ok(job) => serde_json::to_value(&job).unwrap_or_default(),
515 Err(e) => serde_json::json!({ "error": e.to_string() }),
516 }
517 }
518 "cron.list" => {
519 let jobs = cron.list_jobs();
520 serde_json::to_value(&jobs).unwrap_or_default()
521 }
522 "cron.remove" => {
523 let id = cmd_value.get("id").and_then(|v| v.as_str()).unwrap_or("");
524 match cron.remove_job(id) {
525 Ok(Some(job)) => {
526 #[cfg(feature = "exochain")]
527 if let Some(cm) = chain {
528 cm.append(
529 "cron",
530 "cron.remove",
531 Some(serde_json::json!({
532 "job_id": job.id,
533 "name": job.name,
534 "via_agent": pid,
535 })),
536 );
537 }
538 serde_json::json!({"removed": true, "job_id": job.id})
539 }
540 Ok(None) => serde_json::json!({"removed": false, "error": "job not found"}),
541 Err(e) => serde_json::json!({"removed": false, "error": format!("{e}")}),
542 }
543 }
544 "exec" => {
545 let tool_name = cmd_value.get("tool").and_then(|v| v.as_str()).unwrap_or("");
547 let args = cmd_value
548 .get("args")
549 .cloned()
550 .unwrap_or(serde_json::json!({}));
551
552 if tool_name.is_empty() {
553 let text = cmd_value
555 .get("text")
556 .and_then(|v| v.as_str())
557 .unwrap_or("(no input)");
558 serde_json::json!({
559 "status": "ok",
560 "echo": text,
561 "pid": pid,
562 })
563 } else if let Some(registry) = tool_registry {
564 match registry.execute(tool_name, args) {
565 Ok(result) => {
566 #[cfg(feature = "exochain")]
567 if let Some(cm) = chain {
568 cm.append(
569 "tool",
570 "tool.exec",
571 Some(serde_json::json!({
572 "tool": tool_name,
573 "pid": pid,
574 "status": "ok",
575 })),
576 );
577 }
578 serde_json::json!({
579 "status": "ok",
580 "tool": tool_name,
581 "result": result,
582 "pid": pid,
583 })
584 }
585 Err(e) => {
586 #[cfg(feature = "exochain")]
587 if let Some(cm) = chain {
588 cm.append(
589 "tool",
590 "tool.exec",
591 Some(serde_json::json!({
592 "tool": tool_name,
593 "pid": pid,
594 "status": "error",
595 "error": e.to_string(),
596 })),
597 );
598 }
599 serde_json::json!({
600 "error": e.to_string(),
601 "tool": tool_name,
602 "pid": pid,
603 })
604 }
605 }
606 } else {
607 serde_json::json!({
608 "error": "tool registry not available",
609 "tool": tool_name,
610 "pid": pid,
611 })
612 }
613 }
614 "echo" => {
615 let text = cmd_value.get("text").and_then(|v| v.as_str()).unwrap_or("");
616 serde_json::json!({"echo": text, "pid": pid})
617 }
618 "rvf.recv" => {
619 let seg_type = cmd_value
621 .get("segment_type")
622 .and_then(|v| v.as_u64())
623 .unwrap_or(0);
624 let data_len = cmd_value
625 .get("data_len")
626 .and_then(|v| v.as_u64())
627 .unwrap_or(0);
628 serde_json::json!({
629 "status": "ok",
630 "cmd": "rvf.recv",
631 "segment_type": seg_type,
632 "data_len": data_len,
633 "pid": pid,
634 })
635 }
636 unknown => {
637 serde_json::json!({
638 "error": format!("unknown command: {unknown}"),
639 "pid": pid,
640 })
641 }
642 };
643
644 let reply = KernelMessage::with_correlation(
646 pid,
647 MessageTarget::Process(msg.from),
648 MessagePayload::Json(response),
649 msg.id.clone(),
650 );
651
652 #[cfg(feature = "exochain")]
653 {
654 if let Err(e) = a2a.send_checked(reply, chain).await {
655 warn!(pid, error = %e, "failed to send reply");
656 }
657 }
658 #[cfg(not(feature = "exochain"))]
659 {
660 if let Err(e) = a2a.send(reply).await {
661 warn!(pid, error = %e, "failed to send reply");
662 }
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669 use crate::capability::{AgentCapabilities, CapabilityChecker};
670 use crate::process::{ProcessEntry, ProcessState, ProcessTable, ResourceUsage};
671 use crate::topic::TopicRouter;
672
673 fn setup() -> (Arc<A2ARouter>, Arc<CronService>, Arc<ProcessTable>) {
674 let pt = Arc::new(ProcessTable::new(64));
675
676 let kernel_entry = ProcessEntry {
678 pid: 0,
679 agent_id: "kernel".into(),
680 state: ProcessState::Running,
681 capabilities: AgentCapabilities::default(),
682 resource_usage: ResourceUsage::default(),
683 cancel_token: CancellationToken::new(),
684 parent_pid: None,
685 };
686 pt.insert_with_pid(kernel_entry).unwrap();
687
688 let checker = Arc::new(CapabilityChecker::new(pt.clone()));
689 let topic_router = Arc::new(TopicRouter::new(pt.clone()));
690 let a2a = Arc::new(A2ARouter::new(pt.clone(), checker, topic_router));
691 let cron = Arc::new(CronService::new());
692 (a2a, cron, pt)
693 }
694
695 fn spawn_agent(
696 pt: &ProcessTable,
697 a2a: &A2ARouter,
698 agent_id: &str,
699 ) -> (Pid, mpsc::Receiver<KernelMessage>) {
700 let entry = ProcessEntry {
701 pid: 0,
702 agent_id: agent_id.into(),
703 state: ProcessState::Running,
704 capabilities: AgentCapabilities::default(),
705 resource_usage: ResourceUsage::default(),
706 cancel_token: CancellationToken::new(),
707 parent_pid: None,
708 };
709 let pid = pt.insert(entry).unwrap();
710 let rx = a2a.create_inbox(pid);
711 (pid, rx)
712 }
713
714 fn spawn_loop(
716 agent_pid: Pid,
717 cancel: CancellationToken,
718 inbox: mpsc::Receiver<KernelMessage>,
719 a2a: Arc<A2ARouter>,
720 cron: Arc<CronService>,
721 pt: Arc<ProcessTable>,
722 ) -> tokio::task::JoinHandle<i32> {
723 tokio::spawn(async move {
724 kernel_agent_loop(
725 agent_pid,
726 cancel,
727 inbox,
728 a2a,
729 cron,
730 pt,
731 None, #[cfg(feature = "exochain")]
733 None,
734 #[cfg(feature = "exochain")]
735 None,
736 )
737 .await
738 })
739 }
740
741 #[tokio::test]
742 async fn ping_command() {
743 let (a2a, cron, pt) = setup();
744 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
745 let mut kernel_inbox = a2a.create_inbox(0);
746
747 let cancel = CancellationToken::new();
748 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
749
750 let msg = KernelMessage::new(
752 0,
753 MessageTarget::Process(agent_pid),
754 MessagePayload::Json(serde_json::json!({"cmd": "ping"})),
755 );
756 a2a.send(msg).await.unwrap();
757
758 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
760 .await
761 .unwrap()
762 .unwrap();
763
764 if let MessagePayload::Json(v) = &reply.payload {
765 assert_eq!(v["status"], "ok");
766 assert_eq!(v["pid"], agent_pid);
767 } else {
768 panic!("expected JSON reply");
769 }
770
771 cancel.cancel();
772 let code = handle.await.unwrap();
773 assert_eq!(code, 0);
774 }
775
776 #[tokio::test]
777 async fn unknown_command() {
778 let (a2a, cron, pt) = setup();
779 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
780 let mut kernel_inbox = a2a.create_inbox(0);
781
782 let cancel = CancellationToken::new();
783 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
784
785 let msg = KernelMessage::new(
786 0,
787 MessageTarget::Process(agent_pid),
788 MessagePayload::Json(serde_json::json!({"cmd": "nosuch"})),
789 );
790 a2a.send(msg).await.unwrap();
791
792 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
793 .await
794 .unwrap()
795 .unwrap();
796
797 if let MessagePayload::Json(v) = &reply.payload {
798 assert!(v["error"].as_str().unwrap().contains("unknown command"));
799 } else {
800 panic!("expected JSON reply");
801 }
802
803 cancel.cancel();
804 handle.await.unwrap();
805 }
806
807 #[tokio::test]
808 async fn cron_add_via_agent() {
809 let (a2a, cron, pt) = setup();
810 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
811 let mut kernel_inbox = a2a.create_inbox(0);
812
813 let cancel = CancellationToken::new();
814 let handle = spawn_loop(
815 agent_pid,
816 cancel.clone(),
817 inbox,
818 a2a.clone(),
819 cron.clone(),
820 pt,
821 );
822
823 let msg = KernelMessage::new(
824 0,
825 MessageTarget::Process(agent_pid),
826 MessagePayload::Json(serde_json::json!({
827 "cmd": "cron.add",
828 "name": "test-job",
829 "interval_secs": 30,
830 "command": "health",
831 })),
832 );
833 a2a.send(msg).await.unwrap();
834
835 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
836 .await
837 .unwrap()
838 .unwrap();
839
840 if let MessagePayload::Json(v) = &reply.payload {
841 assert_eq!(v["name"], "test-job");
842 assert!(v["id"].as_str().is_some());
843 } else {
844 panic!("expected JSON reply");
845 }
846
847 assert_eq!(cron.job_count(), 1);
849
850 cancel.cancel();
851 handle.await.unwrap();
852 }
853
854 #[tokio::test]
855 async fn cancellation_exits_cleanly() {
856 let (a2a, cron, pt) = setup();
857 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
858
859 let cancel = CancellationToken::new();
860 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a, cron, pt);
861
862 cancel.cancel();
863 let code = handle.await.unwrap();
864 assert_eq!(code, 0);
865 }
866
867 #[tokio::test]
868 async fn rvf_json_payload_processed() {
869 let (a2a, cron, pt) = setup();
870 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
871 let mut kernel_inbox = a2a.create_inbox(0);
872
873 let cancel = CancellationToken::new();
874 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
875
876 let json_bytes = serde_json::to_vec(&serde_json::json!({"cmd": "ping"})).unwrap();
878 let msg = KernelMessage::new(
879 0,
880 MessageTarget::Process(agent_pid),
881 MessagePayload::Rvf {
882 segment_type: 0x40,
883 data: json_bytes,
884 },
885 );
886 a2a.send(msg).await.unwrap();
887
888 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
889 .await
890 .unwrap()
891 .unwrap();
892
893 if let MessagePayload::Json(v) = &reply.payload {
894 assert_eq!(v["status"], "ok");
895 assert_eq!(v["pid"], agent_pid);
896 } else {
897 panic!("expected JSON reply to RVF-wrapped ping");
898 }
899
900 cancel.cancel();
901 handle.await.unwrap();
902 }
903
904 #[tokio::test]
905 async fn rvf_opaque_binary_acknowledged() {
906 let (a2a, cron, pt) = setup();
907 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
908 let mut kernel_inbox = a2a.create_inbox(0);
909
910 let cancel = CancellationToken::new();
911 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
912
913 let msg = KernelMessage::new(
915 0,
916 MessageTarget::Process(agent_pid),
917 MessagePayload::Rvf {
918 segment_type: 0x42,
919 data: vec![0xDE, 0xAD, 0xBE, 0xEF],
920 },
921 );
922 a2a.send(msg).await.unwrap();
923
924 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
925 .await
926 .unwrap()
927 .unwrap();
928
929 if let MessagePayload::Json(v) = &reply.payload {
930 assert_eq!(v["cmd"], "rvf.recv");
931 assert_eq!(v["segment_type"], 0x42);
932 assert_eq!(v["data_len"], 4);
933 } else {
934 panic!("expected JSON reply acknowledging RVF binary");
935 }
936
937 cancel.cancel();
938 handle.await.unwrap();
939 }
940
941 #[tokio::test]
942 async fn resource_usage_increments() {
943 let (a2a, cron, pt) = setup();
944 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
945 let mut kernel_inbox = a2a.create_inbox(0);
946
947 let cancel = CancellationToken::new();
948 let handle = spawn_loop(
949 agent_pid,
950 cancel.clone(),
951 inbox,
952 a2a.clone(),
953 cron,
954 pt.clone(),
955 );
956
957 let msg = KernelMessage::new(
959 0,
960 MessageTarget::Process(agent_pid),
961 MessagePayload::Json(serde_json::json!({"cmd": "ping"})),
962 );
963 a2a.send(msg).await.unwrap();
964
965 let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
967 .await
968 .unwrap()
969 .unwrap();
970
971 cancel.cancel();
973 let _code = handle.await.unwrap();
974
975 let entry = pt.get(agent_pid).unwrap();
977 assert!(
978 entry.resource_usage.messages_sent >= 1,
979 "messages_sent should be at least 1"
980 );
981 }
982
983 #[tokio::test]
984 async fn suspend_resume_cycle() {
985 let (a2a, cron, pt) = setup();
986 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
987 let mut kernel_inbox = a2a.create_inbox(0);
988
989 let cancel = CancellationToken::new();
990 let handle = spawn_loop(
991 agent_pid,
992 cancel.clone(),
993 inbox,
994 a2a.clone(),
995 cron,
996 pt.clone(),
997 );
998
999 let msg = KernelMessage::new(
1001 0,
1002 MessageTarget::Process(agent_pid),
1003 MessagePayload::Json(serde_json::json!({"cmd": "suspend"})),
1004 );
1005 a2a.send(msg).await.unwrap();
1006
1007 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1009 .await
1010 .unwrap()
1011 .unwrap();
1012
1013 if let MessagePayload::Json(v) = &reply.payload {
1014 assert_eq!(v["status"], "suspended");
1015 } else {
1016 panic!("expected JSON reply");
1017 }
1018
1019 let entry = pt.get(agent_pid).unwrap();
1021 assert_eq!(entry.state, ProcessState::Suspended);
1022
1023 let msg = KernelMessage::new(
1025 0,
1026 MessageTarget::Process(agent_pid),
1027 MessagePayload::Json(serde_json::json!({"cmd": "ping"})),
1028 );
1029 a2a.send(msg).await.unwrap();
1030
1031 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1032 .await
1033 .unwrap()
1034 .unwrap();
1035
1036 if let MessagePayload::Json(v) = &reply.payload {
1037 assert_eq!(v["error"], "agent suspended");
1038 } else {
1039 panic!("expected JSON error reply");
1040 }
1041
1042 let msg = KernelMessage::new(
1044 0,
1045 MessageTarget::Process(agent_pid),
1046 MessagePayload::Json(serde_json::json!({"cmd": "resume"})),
1047 );
1048 a2a.send(msg).await.unwrap();
1049
1050 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1051 .await
1052 .unwrap()
1053 .unwrap();
1054
1055 if let MessagePayload::Json(v) = &reply.payload {
1056 assert_eq!(v["status"], "resumed");
1057 } else {
1058 panic!("expected JSON reply");
1059 }
1060
1061 let entry = pt.get(agent_pid).unwrap();
1063 assert_eq!(entry.state, ProcessState::Running);
1064
1065 let msg = KernelMessage::new(
1067 0,
1068 MessageTarget::Process(agent_pid),
1069 MessagePayload::Json(serde_json::json!({"cmd": "ping"})),
1070 );
1071 a2a.send(msg).await.unwrap();
1072
1073 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1074 .await
1075 .unwrap()
1076 .unwrap();
1077
1078 if let MessagePayload::Json(v) = &reply.payload {
1079 assert_eq!(v["status"], "ok");
1080 } else {
1081 panic!("expected JSON reply");
1082 }
1083
1084 cancel.cancel();
1085 handle.await.unwrap();
1086 }
1087
1088 #[cfg(feature = "exochain")]
1089 #[tokio::test]
1090 async fn gate_deny_blocks_exec() {
1091 use crate::gate::{GateBackend, GateDecision};
1092
1093 struct AlwaysDeny;
1095 impl GateBackend for AlwaysDeny {
1096 fn check(
1097 &self,
1098 _agent_id: &str,
1099 _action: &str,
1100 _context: &serde_json::Value,
1101 ) -> GateDecision {
1102 GateDecision::Deny {
1103 reason: "test deny".into(),
1104 receipt: None,
1105 }
1106 }
1107 }
1108
1109 let (a2a, cron, pt) = setup();
1110 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
1111 let mut kernel_inbox = a2a.create_inbox(0);
1112
1113 let cancel = CancellationToken::new();
1114 let cancel2 = cancel.clone();
1115 let a2a2 = a2a.clone();
1116 let pt2 = pt.clone();
1117
1118 let handle = tokio::spawn(async move {
1119 kernel_agent_loop(
1120 agent_pid,
1121 cancel2,
1122 inbox,
1123 a2a2,
1124 cron,
1125 pt2,
1126 None, None, Some(Arc::new(AlwaysDeny) as Arc<dyn GateBackend>),
1129 )
1130 .await
1131 });
1132
1133 let msg = KernelMessage::new(
1135 0,
1136 MessageTarget::Process(agent_pid),
1137 MessagePayload::Json(serde_json::json!({"cmd": "exec", "text": "hello"})),
1138 );
1139 a2a.send(msg).await.unwrap();
1140
1141 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1142 .await
1143 .unwrap()
1144 .unwrap();
1145
1146 if let MessagePayload::Json(v) = &reply.payload {
1147 assert_eq!(v["denied"], true);
1148 assert_eq!(v["error"], "test deny");
1149 } else {
1150 panic!("expected JSON deny reply");
1151 }
1152
1153 cancel.cancel();
1154 handle.await.unwrap();
1155 }
1156
1157 #[cfg(feature = "exochain")]
1158 #[tokio::test]
1159 async fn gate_permit_allows_exec() {
1160 use crate::gate::{GateBackend, GateDecision};
1161
1162 struct AlwaysPermit;
1164 impl GateBackend for AlwaysPermit {
1165 fn check(
1166 &self,
1167 _agent_id: &str,
1168 _action: &str,
1169 _context: &serde_json::Value,
1170 ) -> GateDecision {
1171 GateDecision::Permit { token: None }
1172 }
1173 }
1174
1175 let (a2a, cron, pt) = setup();
1176 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "test-agent");
1177 let mut kernel_inbox = a2a.create_inbox(0);
1178
1179 let cancel = CancellationToken::new();
1180 let cancel2 = cancel.clone();
1181 let a2a2 = a2a.clone();
1182 let pt2 = pt.clone();
1183
1184 let handle = tokio::spawn(async move {
1185 kernel_agent_loop(
1186 agent_pid,
1187 cancel2,
1188 inbox,
1189 a2a2,
1190 cron,
1191 pt2,
1192 None, None, Some(Arc::new(AlwaysPermit) as Arc<dyn GateBackend>),
1195 )
1196 .await
1197 });
1198
1199 let msg = KernelMessage::new(
1201 0,
1202 MessageTarget::Process(agent_pid),
1203 MessagePayload::Json(serde_json::json!({"cmd": "exec", "text": "hello"})),
1204 );
1205 a2a.send(msg).await.unwrap();
1206
1207 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1208 .await
1209 .unwrap()
1210 .unwrap();
1211
1212 if let MessagePayload::Json(v) = &reply.payload {
1213 assert_eq!(v["status"], "ok");
1214 assert_eq!(v["echo"], "hello");
1215 } else {
1216 panic!("expected JSON reply");
1217 }
1218
1219 cancel.cancel();
1220 handle.await.unwrap();
1221 }
1222
1223 #[cfg(feature = "exochain")]
1224 #[tokio::test]
1225 async fn chain_logs_ipc_recv_ack() {
1226 let (a2a, cron, pt) = setup();
1227 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "chain-test");
1228 let mut kernel_inbox = a2a.create_inbox(0);
1229
1230 let cm = Arc::new(crate::chain::ChainManager::new(0, 1000));
1231 let cancel = CancellationToken::new();
1232 let cancel2 = cancel.clone();
1233 let a2a2 = a2a.clone();
1234 let pt2 = pt.clone();
1235 let cm2 = cm.clone();
1236
1237 let handle = tokio::spawn(async move {
1238 kernel_agent_loop(
1239 agent_pid,
1240 cancel2,
1241 inbox,
1242 a2a2,
1243 cron,
1244 pt2,
1245 None, Some(cm2),
1247 None, )
1249 .await
1250 });
1251
1252 let msg = KernelMessage::new(
1254 0,
1255 MessageTarget::Process(agent_pid),
1256 MessagePayload::Json(serde_json::json!({"cmd": "ping"})),
1257 );
1258 let msg_id = msg.id.clone();
1259 a2a.send(msg).await.unwrap();
1260
1261 let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1263 .await
1264 .unwrap()
1265 .unwrap();
1266
1267 cancel.cancel();
1268 handle.await.unwrap();
1269
1270 let events = cm.tail(10);
1272 let recv_evt = events.iter().find(|e| e.kind == "ipc.recv");
1273 let ack_evt = events.iter().find(|e| e.kind == "ipc.ack");
1274
1275 assert!(recv_evt.is_some(), "expected ipc.recv event on chain");
1276 assert!(ack_evt.is_some(), "expected ipc.ack event on chain");
1277
1278 let recv_payload = recv_evt.unwrap().payload.as_ref().unwrap();
1279 assert_eq!(recv_payload["pid"], agent_pid);
1280 assert_eq!(recv_payload["from"], 0);
1281 assert_eq!(recv_payload["msg_id"], msg_id);
1282 assert_eq!(recv_payload["cmd"], "ping");
1283
1284 let ack_payload = ack_evt.unwrap().payload.as_ref().unwrap();
1285 assert_eq!(ack_payload["pid"], agent_pid);
1286 assert_eq!(ack_payload["msg_id"], msg_id);
1287 assert_eq!(ack_payload["cmd"], "ping");
1288 assert_eq!(ack_payload["status"], "processed");
1289 }
1290
1291 #[cfg(feature = "exochain")]
1292 #[tokio::test]
1293 async fn chain_logs_suspend_resume() {
1294 let (a2a, cron, pt) = setup();
1295 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "suspend-test");
1296 let mut kernel_inbox = a2a.create_inbox(0);
1297
1298 let cm = Arc::new(crate::chain::ChainManager::new(0, 1000));
1299 let cancel = CancellationToken::new();
1300 let cancel2 = cancel.clone();
1301 let a2a2 = a2a.clone();
1302 let pt2 = pt.clone();
1303 let cm2 = cm.clone();
1304
1305 let handle = tokio::spawn(async move {
1306 kernel_agent_loop(
1307 agent_pid,
1308 cancel2,
1309 inbox,
1310 a2a2,
1311 cron,
1312 pt2,
1313 None, Some(cm2),
1315 None, )
1317 .await
1318 });
1319
1320 let suspend_msg = KernelMessage::new(
1322 0,
1323 MessageTarget::Process(agent_pid),
1324 MessagePayload::Json(serde_json::json!({"cmd": "suspend"})),
1325 );
1326 let suspend_id = suspend_msg.id.clone();
1327 a2a.send(suspend_msg).await.unwrap();
1328
1329 let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1331 .await
1332 .unwrap()
1333 .unwrap();
1334
1335 let resume_msg = KernelMessage::new(
1337 0,
1338 MessageTarget::Process(agent_pid),
1339 MessagePayload::Json(serde_json::json!({"cmd": "resume"})),
1340 );
1341 let resume_id = resume_msg.id.clone();
1342 a2a.send(resume_msg).await.unwrap();
1343
1344 let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1346 .await
1347 .unwrap()
1348 .unwrap();
1349
1350 cancel.cancel();
1351 handle.await.unwrap();
1352
1353 let events = cm.tail(20);
1355 let suspend_evt = events.iter().find(|e| e.kind == "agent.suspend");
1356 let resume_evt = events.iter().find(|e| e.kind == "agent.resume");
1357
1358 assert!(
1359 suspend_evt.is_some(),
1360 "expected agent.suspend event on chain"
1361 );
1362 assert!(resume_evt.is_some(), "expected agent.resume event on chain");
1363
1364 let sp = suspend_evt.unwrap().payload.as_ref().unwrap();
1365 assert_eq!(sp["pid"], agent_pid);
1366 assert_eq!(sp["from"], 0);
1367 assert_eq!(sp["msg_id"], suspend_id);
1368
1369 let rp = resume_evt.unwrap().payload.as_ref().unwrap();
1370 assert_eq!(rp["pid"], agent_pid);
1371 assert_eq!(rp["from"], 0);
1372 assert_eq!(rp["msg_id"], resume_id);
1373 }
1374
1375 #[tokio::test]
1378 async fn echo_command() {
1379 let (a2a, cron, pt) = setup();
1380 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "echo-agent");
1381 let mut kernel_inbox = a2a.create_inbox(0);
1382
1383 let cancel = CancellationToken::new();
1384 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1385
1386 let msg = KernelMessage::new(
1387 0,
1388 MessageTarget::Process(agent_pid),
1389 MessagePayload::Json(serde_json::json!({"cmd": "echo", "text": "hello world"})),
1390 );
1391 a2a.send(msg).await.unwrap();
1392
1393 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1394 .await
1395 .unwrap()
1396 .unwrap();
1397
1398 if let MessagePayload::Json(v) = &reply.payload {
1399 assert_eq!(v["echo"], "hello world");
1400 assert_eq!(v["pid"], agent_pid);
1401 } else {
1402 panic!("expected JSON reply");
1403 }
1404
1405 cancel.cancel();
1406 handle.await.unwrap();
1407 }
1408
1409 #[tokio::test]
1410 async fn text_payload_parsed_as_json() {
1411 let (a2a, cron, pt) = setup();
1412 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "text-agent");
1413 let mut kernel_inbox = a2a.create_inbox(0);
1414
1415 let cancel = CancellationToken::new();
1416 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1417
1418 let msg = KernelMessage::new(
1420 0,
1421 MessageTarget::Process(agent_pid),
1422 MessagePayload::Text(r#"{"cmd": "ping"}"#.into()),
1423 );
1424 a2a.send(msg).await.unwrap();
1425
1426 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1427 .await
1428 .unwrap()
1429 .unwrap();
1430
1431 if let MessagePayload::Json(v) = &reply.payload {
1432 assert_eq!(v["status"], "ok");
1433 } else {
1434 panic!("expected JSON reply");
1435 }
1436
1437 cancel.cancel();
1438 handle.await.unwrap();
1439 }
1440
1441 #[tokio::test]
1442 async fn text_payload_non_json_becomes_echo() {
1443 let (a2a, cron, pt) = setup();
1444 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "text-echo-agent");
1445 let mut kernel_inbox = a2a.create_inbox(0);
1446
1447 let cancel = CancellationToken::new();
1448 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1449
1450 let msg = KernelMessage::new(
1452 0,
1453 MessageTarget::Process(agent_pid),
1454 MessagePayload::Text("just plain text".into()),
1455 );
1456 a2a.send(msg).await.unwrap();
1457
1458 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1459 .await
1460 .unwrap()
1461 .unwrap();
1462
1463 if let MessagePayload::Json(v) = &reply.payload {
1464 assert_eq!(v["echo"], "just plain text");
1466 } else {
1467 panic!("expected JSON reply");
1468 }
1469
1470 cancel.cancel();
1471 handle.await.unwrap();
1472 }
1473
1474 #[tokio::test]
1475 async fn inbox_close_causes_clean_exit() {
1476 let pt = Arc::new(ProcessTable::new(64));
1477
1478 let kernel_entry = ProcessEntry {
1480 pid: 0,
1481 agent_id: "kernel".into(),
1482 state: ProcessState::Running,
1483 capabilities: AgentCapabilities::default(),
1484 resource_usage: ResourceUsage::default(),
1485 cancel_token: CancellationToken::new(),
1486 parent_pid: None,
1487 };
1488 pt.insert_with_pid(kernel_entry).unwrap();
1489
1490 let checker = Arc::new(CapabilityChecker::new(pt.clone()));
1491 let topic_router = Arc::new(TopicRouter::new(pt.clone()));
1492 let a2a = Arc::new(A2ARouter::new(pt.clone(), checker, topic_router));
1493 let cron = Arc::new(CronService::new());
1494
1495 let (tx, rx) = mpsc::channel(32);
1497 let agent_entry = ProcessEntry {
1498 pid: 0,
1499 agent_id: "close-agent".into(),
1500 state: ProcessState::Running,
1501 capabilities: AgentCapabilities::default(),
1502 resource_usage: ResourceUsage::default(),
1503 cancel_token: CancellationToken::new(),
1504 parent_pid: None,
1505 };
1506 let agent_pid = pt.insert(agent_entry).unwrap();
1507
1508 let cancel = CancellationToken::new();
1509 let handle = spawn_loop(agent_pid, cancel.clone(), rx, a2a, cron, pt);
1510
1511 drop(tx);
1513
1514 let code = tokio::time::timeout(std::time::Duration::from_secs(2), handle)
1515 .await
1516 .unwrap()
1517 .unwrap();
1518
1519 assert_eq!(code, 0, "should exit cleanly when inbox closes");
1520 }
1521
1522 #[tokio::test]
1523 async fn cron_list_returns_empty() {
1524 let (a2a, cron, pt) = setup();
1525 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "cron-list-agent");
1526 let mut kernel_inbox = a2a.create_inbox(0);
1527
1528 let cancel = CancellationToken::new();
1529 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1530
1531 let msg = KernelMessage::new(
1532 0,
1533 MessageTarget::Process(agent_pid),
1534 MessagePayload::Json(serde_json::json!({"cmd": "cron.list"})),
1535 );
1536 a2a.send(msg).await.unwrap();
1537
1538 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1539 .await
1540 .unwrap()
1541 .unwrap();
1542
1543 if let MessagePayload::Json(v) = &reply.payload {
1544 assert!(v.is_array(), "cron.list should return array");
1545 assert_eq!(v.as_array().unwrap().len(), 0, "should be empty");
1546 } else {
1547 panic!("expected JSON reply");
1548 }
1549
1550 cancel.cancel();
1551 handle.await.unwrap();
1552 }
1553
1554 #[tokio::test]
1555 async fn cron_remove_nonexistent() {
1556 let (a2a, cron, pt) = setup();
1557 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "cron-rm-agent");
1558 let mut kernel_inbox = a2a.create_inbox(0);
1559
1560 let cancel = CancellationToken::new();
1561 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1562
1563 let msg = KernelMessage::new(
1564 0,
1565 MessageTarget::Process(agent_pid),
1566 MessagePayload::Json(serde_json::json!({"cmd": "cron.remove", "id": "nonexistent"})),
1567 );
1568 a2a.send(msg).await.unwrap();
1569
1570 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1571 .await
1572 .unwrap()
1573 .unwrap();
1574
1575 if let MessagePayload::Json(v) = &reply.payload {
1576 assert_eq!(v["removed"], false);
1577 assert!(v["error"].as_str().is_some());
1578 } else {
1579 panic!("expected JSON reply");
1580 }
1581
1582 cancel.cancel();
1583 handle.await.unwrap();
1584 }
1585
1586 #[tokio::test]
1587 async fn exec_without_tool_echoes() {
1588 let (a2a, cron, pt) = setup();
1589 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "exec-agent");
1590 let mut kernel_inbox = a2a.create_inbox(0);
1591
1592 let cancel = CancellationToken::new();
1593 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1594
1595 let msg = KernelMessage::new(
1597 0,
1598 MessageTarget::Process(agent_pid),
1599 MessagePayload::Json(serde_json::json!({"cmd": "exec", "text": "fallback"})),
1600 );
1601 a2a.send(msg).await.unwrap();
1602
1603 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1604 .await
1605 .unwrap()
1606 .unwrap();
1607
1608 if let MessagePayload::Json(v) = &reply.payload {
1609 assert_eq!(v["status"], "ok");
1610 assert_eq!(v["echo"], "fallback");
1611 } else {
1612 panic!("expected JSON reply");
1613 }
1614
1615 cancel.cancel();
1616 handle.await.unwrap();
1617 }
1618
1619 #[tokio::test]
1620 async fn exec_with_tool_name_no_registry() {
1621 let (a2a, cron, pt) = setup();
1622 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "exec-noreg-agent");
1623 let mut kernel_inbox = a2a.create_inbox(0);
1624
1625 let cancel = CancellationToken::new();
1626 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1627
1628 let msg = KernelMessage::new(
1630 0,
1631 MessageTarget::Process(agent_pid),
1632 MessagePayload::Json(serde_json::json!({"cmd": "exec", "tool": "fs.read", "args": {}})),
1633 );
1634 a2a.send(msg).await.unwrap();
1635
1636 let reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1637 .await
1638 .unwrap()
1639 .unwrap();
1640
1641 if let MessagePayload::Json(v) = &reply.payload {
1642 assert!(
1643 v["error"]
1644 .as_str()
1645 .unwrap()
1646 .contains("tool registry not available"),
1647 "should report tool registry unavailable"
1648 );
1649 } else {
1650 panic!("expected JSON reply");
1651 }
1652
1653 cancel.cancel();
1654 handle.await.unwrap();
1655 }
1656
1657 #[tokio::test]
1658 async fn multiple_messages_increment_usage() {
1659 let (a2a, cron, pt) = setup();
1660 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "multi-msg-agent");
1661 let mut kernel_inbox = a2a.create_inbox(0);
1662
1663 let cancel = CancellationToken::new();
1664 let handle = spawn_loop(
1665 agent_pid,
1666 cancel.clone(),
1667 inbox,
1668 a2a.clone(),
1669 cron,
1670 pt.clone(),
1671 );
1672
1673 for _ in 0..3 {
1675 let msg = KernelMessage::new(
1676 0,
1677 MessageTarget::Process(agent_pid),
1678 MessagePayload::Json(serde_json::json!({"cmd": "ping"})),
1679 );
1680 a2a.send(msg).await.unwrap();
1681 let _reply =
1682 tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1683 .await
1684 .unwrap()
1685 .unwrap();
1686 }
1687
1688 cancel.cancel();
1689 let _code = handle.await.unwrap();
1690
1691 let entry = pt.get(agent_pid).unwrap();
1692 assert!(
1693 entry.resource_usage.messages_sent >= 3,
1694 "should have sent at least 3 messages, got {}",
1695 entry.resource_usage.messages_sent
1696 );
1697 }
1698
1699 #[tokio::test]
1700 async fn cancel_during_suspend_exits_cleanly() {
1701 let (a2a, cron, pt) = setup();
1702 let (agent_pid, inbox) = spawn_agent(&pt, &a2a, "cancel-suspend-agent");
1703 let mut kernel_inbox = a2a.create_inbox(0);
1704
1705 let cancel = CancellationToken::new();
1706 let handle = spawn_loop(agent_pid, cancel.clone(), inbox, a2a.clone(), cron, pt);
1707
1708 let msg = KernelMessage::new(
1710 0,
1711 MessageTarget::Process(agent_pid),
1712 MessagePayload::Json(serde_json::json!({"cmd": "suspend"})),
1713 );
1714 a2a.send(msg).await.unwrap();
1715
1716 let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1718 .await
1719 .unwrap()
1720 .unwrap();
1721
1722 cancel.cancel();
1724 let code = tokio::time::timeout(std::time::Duration::from_secs(2), handle)
1725 .await
1726 .unwrap()
1727 .unwrap();
1728
1729 assert_eq!(code, 0, "should exit cleanly when cancelled during suspend");
1730 }
1731
1732 #[tokio::test]
1733 async fn extract_cmd_from_json() {
1734 let msg = KernelMessage::new(
1735 0,
1736 MessageTarget::Process(1),
1737 MessagePayload::Json(serde_json::json!({"cmd": "test_cmd"})),
1738 );
1739 assert_eq!(extract_cmd(&msg), Some("test_cmd".to_string()));
1740 }
1741
1742 #[tokio::test]
1743 async fn extract_cmd_from_text() {
1744 let msg = KernelMessage::new(
1745 0,
1746 MessageTarget::Process(1),
1747 MessagePayload::Text(r#"{"cmd": "from_text"}"#.into()),
1748 );
1749 assert_eq!(extract_cmd(&msg), Some("from_text".to_string()));
1750 }
1751
1752 #[tokio::test]
1753 async fn extract_cmd_from_plain_text_returns_none() {
1754 let msg = KernelMessage::new(
1755 0,
1756 MessageTarget::Process(1),
1757 MessagePayload::Text("not json".into()),
1758 );
1759 assert_eq!(extract_cmd(&msg), None);
1760 }
1761
1762 #[tokio::test]
1763 async fn extract_cmd_from_signal_returns_none() {
1764 let msg = KernelMessage::new(
1765 0,
1766 MessageTarget::Process(1),
1767 MessagePayload::Signal(crate::ipc::KernelSignal::Shutdown),
1768 );
1769 assert_eq!(extract_cmd(&msg), None);
1770 }
1771}