Skip to main content

clawft_kernel/
agent_loop.rs

1//! Built-in kernel agent work loop.
2//!
3//! Every daemon-spawned agent runs this loop. It receives messages
4//! from the A2ARouter inbox and processes built-in commands.
5
6use 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/// Run the built-in kernel agent work loop.
19///
20/// The agent:
21/// 1. Receives messages from its A2ARouter inbox
22/// 2. Processes built-in commands dispatched as JSON `{"cmd": "..."}` payloads
23/// 3. Sends responses back via A2ARouter
24/// 4. Tracks resource usage (messages_sent, tool_calls, cpu_time_ms)
25/// 5. Supports suspend/resume via `{"cmd":"suspend"}` / `{"cmd":"resume"}`
26/// 6. Enforces gate checks before exec/cron commands (when gate is provided)
27/// 7. Exits when the cancellation token is triggered
28///
29/// Returns an exit code (0 = normal shutdown).
30#[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    // Extract agent_id once before the loop (used by gate checks)
46    #[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                // Final resource update
59                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                        // Log message receipt on chain
69                        #[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                        // Handle suspend command
84                        if cmd.as_deref() == Some("suspend") {
85                            // Send acknowledgement BEFORE transitioning state,
86                            // because A2ARouter.send() checks sender is Running.
87                            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                            // Transition to Suspended
100                            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                            // Enter parking loop
117                            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                                // Cancelled during suspend
129                                usage.cpu_time_ms = started.elapsed().as_millis() as u64;
130                                let _ = process_table.update_resources(pid, usage);
131                                return 0;
132                            }
133                            // Resumed — continue main loop
134                            continue;
135                        }
136
137                        // Gate check for protected commands
138                        #[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                                // Build enriched gate context with tool name and effect vector (K4 A2)
150                                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                                        // Permitted — continue with normal handling
204                                    }
205                                }
206                            }
207                        }
208
209                        // Track tool_calls for exec command and log sudo usage
210                        if cmd.as_deref() == Some("exec") {
211                            usage.tool_calls += 1;
212
213                            // K4 B1: Log sudo override usage to chain
214                            #[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                        // Log message acknowledgement on chain
250                        #[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                        // Update resource counters every 10 messages and periodically
265                        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                        // Inbox closed — shutdown
272                        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
283/// Extract the command string from a message payload.
284fn 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/// Extract the tool name from an exec message payload.
299#[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
310/// Parking loop for suspended agents.
311///
312/// Waits for either a resume command or cancellation. Returns `true`
313/// if resumed, `false` if cancelled.
314async 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                            // Transition back to Running
335                            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                        // All other commands while suspended get an error
366                        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                        // Inbox closed while suspended
380                        return false;
381                    }
382                }
383            }
384        }
385    }
386}
387
388/// Send a reply message through the A2ARouter.
389async 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
408/// Handle a single inbound message.
409async 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    // Extract command from payload — supports JSON, Text, and RVF envelopes.
419    let cmd_value = match &msg.payload {
420        MessagePayload::Json(v) => v.clone(),
421        MessagePayload::Text(text) => {
422            // Try parsing text as JSON, otherwise treat as plain text
423            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            // Decode RVF-typed payloads:
430            //   0x40 (ExochainEvent) — treat inner CBOR/JSON as command
431            //   Other — wrap as a typed envelope for the agent
432            debug!(
433                pid,
434                segment_type,
435                data_len = data.len(),
436                "received RVF payload"
437            );
438
439            // With exochain: try CBOR decode first (rvf-wire format), then JSON
440            #[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            // Without exochain: try JSON decode, fall back to rvf.recv
463            #[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            // K3 tool dispatch via ToolRegistry
546            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                // Backwards compat: echo mode when no tool specified
554                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            // Acknowledge receipt of an RVF-typed payload
620            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    // Send response back to sender via chain-logged path
645    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        // Insert a "kernel" process at PID 0 for message routing
677        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    /// Helper to spawn the agent loop with the new parameters.
715    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, // tool_registry
732                #[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        // Send ping from kernel (PID 0)
751        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        // Wait for reply
759        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        // Verify job was actually added
848        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        // Send an RVF payload containing JSON bytes (e.g. `{"cmd":"ping"}`)
877        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        // Send raw binary that isn't valid JSON or CBOR
914        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        // Send a ping
958        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        // Wait for reply
966        let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
967            .await
968            .unwrap()
969            .unwrap();
970
971        // Cancel and wait for the loop to exit (which triggers final resource update)
972        cancel.cancel();
973        let _code = handle.await.unwrap();
974
975        // Check resource usage was updated
976        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        // Send suspend
1000        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        // Wait for suspended acknowledgement
1008        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        // Verify process state is Suspended
1020        let entry = pt.get(agent_pid).unwrap();
1021        assert_eq!(entry.state, ProcessState::Suspended);
1022
1023        // Send a ping while suspended — should get error
1024        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        // Send resume
1043        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        // Verify process state is Running again
1062        let entry = pt.get(agent_pid).unwrap();
1063        assert_eq!(entry.state, ProcessState::Running);
1064
1065        // Ping should work again after resume
1066        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        // Gate that always denies
1094        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, // tool_registry
1127                None, // chain
1128                Some(Arc::new(AlwaysDeny) as Arc<dyn GateBackend>),
1129            )
1130            .await
1131        });
1132
1133        // Send exec — should be denied by gate
1134        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        // Gate that always permits
1163        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, // tool_registry
1193                None, // chain
1194                Some(Arc::new(AlwaysPermit) as Arc<dyn GateBackend>),
1195            )
1196            .await
1197        });
1198
1199        // Send exec — should be permitted
1200        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, // tool_registry
1246                Some(cm2),
1247                None, // gate
1248            )
1249            .await
1250        });
1251
1252        // Send ping from kernel (PID 0)
1253        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        // Wait for reply
1262        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        // Verify chain events: ipc.recv + ipc.ack (plus ipc.send from reply)
1271        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, // tool_registry
1314                Some(cm2),
1315                None, // gate
1316            )
1317            .await
1318        });
1319
1320        // Send suspend
1321        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        // Wait for suspended ack
1330        let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1331            .await
1332            .unwrap()
1333            .unwrap();
1334
1335        // Send resume
1336        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        // Wait for resumed ack
1345        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        // Verify chain events
1354        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    // ── Additional agent_loop coverage tests ─────────────────────
1376
1377    #[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        // Send text payload that is valid JSON
1419        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        // Send plain text that isn't JSON
1451        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            // Plain text becomes {"cmd": "echo", "text": "just plain text"}
1465            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        // Insert kernel process
1479        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        // Create agent with a manually controlled inbox
1496        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 the sender to close the inbox
1512        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        // exec without tool name falls back to echo mode
1596        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        // exec with tool name but no registry
1629        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        // Send 3 pings
1674        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        // Send suspend
1709        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        // Wait for suspended ack
1717        let _reply = tokio::time::timeout(std::time::Duration::from_secs(1), kernel_inbox.recv())
1718            .await
1719            .unwrap()
1720            .unwrap();
1721
1722        // Cancel while suspended
1723        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}