Skip to main content

microclaw_engine/
headless.rs

1//! Headless one-shot mode: `microclaw run -p "<prompt>"`.
2//!
3//! Runs a single prompt through the shared agent loop without starting any
4//! channel, prints the final reply to stdout, and exits non-zero on failure
5//! — the scripting/CI entry point (grok-build's headless mode equivalent).
6//! Runs share the `headless` channel; `--session <name>` selects a named
7//! chat so consecutive runs can keep conversation context.
8
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::Arc;
12
13use tokio::sync::{mpsc, RwLock};
14
15use crate::agent_engine::{
16    process_with_agent_profile_guarded_mode, AgentEvent, AgentRequestContext,
17};
18use crate::chat_turn_queue::PendingMessage;
19use crate::config::Config;
20use crate::hooks::HookManager;
21use crate::internal::channels::channel_adapter::ChannelRegistry;
22use crate::internal::runtime::{ExecutionContext, ExecutionResult, RunExecutor};
23use crate::internal::storage::db::{call_blocking, Database, StoredMessage};
24use crate::memory::MemoryManager;
25use crate::memory_backend::{MemoryBackend, MemoryMcpClient};
26use crate::runtime::AppState;
27use crate::skill_management::{archive_skill, install_skill, SkillInstallOutcome};
28use crate::skills::{SkillAvailability, SkillManager};
29use crate::tools::ToolRegistry;
30use crate::{embedding, llm};
31use microclaw_core::run_protocol::{
32    AgentProfile, RunRequest, RuntimeCapabilities, RuntimeControl, RuntimeError, RuntimeErrorCode,
33    SessionId,
34};
35use microclaw_core::runtime_event::RuntimeEventEnvelope;
36
37pub const HEADLESS_CHANNEL: &str = "headless";
38pub const WORK_CHANNEL: &str = "work";
39
40#[derive(Debug, Clone)]
41pub struct HeadlessRunRequest {
42    pub prompt: String,
43    pub session: Option<String>,
44    pub sender_name: String,
45    pub run_id: Option<String>,
46    pub caller_channel: String,
47}
48
49impl HeadlessRunRequest {
50    pub fn cli(prompt: String, session: Option<String>) -> Self {
51        Self {
52            prompt,
53            session,
54            sender_name: "cli".into(),
55            run_id: None,
56            caller_channel: HEADLESS_CHANNEL.into(),
57        }
58    }
59
60    pub fn work(prompt: String, session: Option<String>, run_id: String) -> Self {
61        Self {
62            prompt,
63            session,
64            sender_name: "work".into(),
65            run_id: Some(run_id),
66            caller_channel: WORK_CHANNEL.into(),
67        }
68    }
69}
70
71#[derive(Debug, Clone)]
72pub struct HeadlessRunResult {
73    pub run_id: String,
74    pub chat_id: i64,
75    pub response: String,
76}
77
78/// UI-neutral projection of one durable Subagent run.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct HeadlessSubagentRun {
81    pub run_id: String,
82    pub parent_run_id: Option<String>,
83    pub depth: u32,
84    pub label: Option<String>,
85    pub task: String,
86    pub status: String,
87    pub progress: Option<String>,
88    pub result: Option<String>,
89    pub error: Option<String>,
90    pub created_at: String,
91    pub started_at: Option<String>,
92    pub finished_at: Option<String>,
93    pub cancel_requested: bool,
94}
95
96/// Reusable application service for one-shot tasks.
97///
98/// It owns the existing provider-neutral Agent Engine state but starts no
99/// channel, Web server, or scheduler. Both the CLI and Work adapters call this
100/// service instead of assembling or copying an Agent Loop.
101#[derive(Clone)]
102pub struct HeadlessRuntime {
103    state: Arc<AppState>,
104}
105
106/// Adapter from the existing shared Agent Engine to the embeddable Runtime contract.
107///
108/// This is intentionally channel-free: the caller selects the logical channel used for policy,
109/// persistence, cancellation, and steering. Product UIs consume `microclaw-engine` rather than
110/// calling the Agent Engine directly.
111pub struct HeadlessRunExecutor {
112    runtime: HeadlessRuntime,
113    caller_channel: String,
114}
115
116impl HeadlessRunExecutor {
117    pub fn new(runtime: HeadlessRuntime, caller_channel: impl Into<String>) -> Self {
118        Self {
119            runtime,
120            caller_channel: caller_channel.into(),
121        }
122    }
123}
124
125#[async_trait::async_trait]
126impl RunExecutor for HeadlessRunExecutor {
127    async fn execute(
128        &self,
129        profile: AgentProfile,
130        request: RunRequest,
131        mut context: ExecutionContext,
132    ) -> Result<ExecutionResult, RuntimeError> {
133        let session = request
134            .session_id
135            .clone()
136            .unwrap_or_else(|| SessionId::new("default"));
137        let (legacy_event_tx, mut legacy_event_rx) = mpsc::unbounded_channel();
138        let run = self.runtime.run_with_profile(
139            HeadlessRunRequest {
140                prompt: request.prompt,
141                session: Some(session.to_string()),
142                sender_name: self.caller_channel.clone(),
143                run_id: Some(context.run_id().to_string()),
144                caller_channel: self.caller_channel.clone(),
145            },
146            profile,
147            Some(legacy_event_tx),
148        );
149        tokio::pin!(run);
150
151        let result = loop {
152            tokio::select! {
153                result = &mut run => break result,
154                event = legacy_event_rx.recv() => {
155                    if let Some(envelope) = event {
156                        forward_runtime_event(&context, envelope.event)?;
157                    }
158                }
159                control = context.next_control_request() => {
160                    if let Some(control) = control {
161                        match control.control().clone() {
162                        RuntimeControl::Cancel { .. } => {
163                            let cancelled = self.runtime
164                                .cancel_session_for_channel(&self.caller_channel, session.as_str())
165                                .await
166                                .map_err(runtime_internal_error)?;
167                            if cancelled > 0 {
168                                control.accept();
169                            } else {
170                                control.reject(RuntimeError {
171                                    code: RuntimeErrorCode::Unavailable,
172                                    message: "the run finished before cancellation was accepted".into(),
173                                    retryable: false,
174                                });
175                            }
176                        }
177                        RuntimeControl::Steer { message, .. } => {
178                            let accepted = self.runtime
179                                .steer_session_for_channel(
180                                    &self.caller_channel,
181                                    session.as_str(),
182                                    &message,
183                                )
184                                .await
185                                .map_err(runtime_internal_error)?;
186                            if accepted {
187                                control.accept();
188                            } else {
189                                control.reject(RuntimeError {
190                                    code: RuntimeErrorCode::Unavailable,
191                                    message: "the run finished before the update could be queued".into(),
192                                    retryable: false,
193                                });
194                            }
195                        }
196                        RuntimeControl::ResolveApproval { .. } => {
197                            control.reject(RuntimeError {
198                                code: RuntimeErrorCode::Unavailable,
199                                message: "the current Agent Engine approval bridge is unavailable"
200                                    .into(),
201                                retryable: false,
202                            });
203                        }
204                        }
205                    }
206                }
207            }
208        }
209        .map_err(runtime_internal_error)?;
210
211        while let Ok(envelope) = legacy_event_rx.try_recv() {
212            forward_runtime_event(&context, envelope.event)?;
213        }
214
215        let mut output = ExecutionResult::new(session, result.response);
216        output.metadata.insert(
217            "chat_id".into(),
218            serde_json::Value::Number(result.chat_id.into()),
219        );
220        Ok(output)
221    }
222
223    fn capabilities(&self) -> RuntimeCapabilities {
224        RuntimeCapabilities {
225            streaming: true,
226            cancellation: true,
227            steering: true,
228            approvals: false,
229            skills: true,
230            subagents: true,
231            remote_workers: false,
232        }
233    }
234}
235
236fn forward_runtime_event(
237    context: &ExecutionContext,
238    event: microclaw_core::runtime_event::RuntimeEvent,
239) -> Result<(), RuntimeError> {
240    if matches!(
241        event,
242        microclaw_core::runtime_event::RuntimeEvent::FinalResponse { .. }
243            | microclaw_core::runtime_event::RuntimeEvent::Cancelled { .. }
244    ) {
245        return Ok(());
246    }
247    context.emit(event)
248}
249
250fn runtime_internal_error(error: impl std::fmt::Display) -> RuntimeError {
251    RuntimeError {
252        code: RuntimeErrorCode::Internal,
253        message: error.to_string(),
254        retryable: false,
255    }
256}
257
258impl HeadlessRuntime {
259    /// Return the Skills visible to this runtime, including availability diagnostics when
260    /// requested. Embedding facades use this to present a catalog without exposing runtime state.
261    pub fn skill_catalog(&self, include_unavailable: bool) -> Vec<SkillAvailability> {
262        self.state
263            .skills
264            .discover_skills_with_status(include_unavailable)
265    }
266
267    /// Invalidate cached integrity verdicts and rebuild the runtime's Skill catalog.
268    pub fn reload_skill_catalog(&self, include_unavailable: bool) -> Vec<SkillAvailability> {
269        self.state.skills.reload();
270        self.skill_catalog(include_unavailable)
271    }
272
273    /// Change runtime-scoped Skill enablement without rewriting the Skill package.
274    pub fn set_skill_enabled(&self, name: &str, enabled: bool) -> Result<(), String> {
275        self.state.skills.set_enabled(name, enabled)?;
276        self.state.skills.reload();
277        Ok(())
278    }
279
280    /// Install or update a Skill through the shared governed package path.
281    pub async fn install_skill(&self, reference: &str) -> Result<SkillInstallOutcome, String> {
282        install_skill(&self.state.config, &self.state.skills, reference).await
283    }
284
285    /// Recoverably remove a Skill and return its archive location.
286    pub fn archive_skill(&self, name: &str) -> Result<std::path::PathBuf, String> {
287        archive_skill(&self.state.skills, name)
288    }
289
290    /// List durable Subagent runs belonging to a named embedded session.
291    pub fn subagent_runs(
292        &self,
293        session: &str,
294        limit: usize,
295    ) -> anyhow::Result<Vec<HeadlessSubagentRun>> {
296        let external_chat_id = format!("headless:{}", session.trim());
297        let Some(chat_id) = self
298            .state
299            .db
300            .find_chat_id(HEADLESS_CHANNEL, &external_chat_id)?
301        else {
302            return Ok(Vec::new());
303        };
304        Ok(self
305            .state
306            .db
307            .list_subagent_runs(chat_id, limit.clamp(1, 1_000))?
308            .into_iter()
309            .map(|run| HeadlessSubagentRun {
310                run_id: run.run_id,
311                parent_run_id: run.parent_run_id,
312                depth: run.depth.max(0) as u32,
313                label: run.label,
314                task: run.task,
315                status: run.status,
316                progress: run.progress_text,
317                result: run.result_text,
318                error: run.error_text,
319                created_at: run.created_at,
320                started_at: run.started_at,
321                finished_at: run.finished_at,
322                cancel_requested: run.cancel_requested,
323            })
324            .collect())
325    }
326
327    /// Persist and promptly signal cancellation for one Subagent owned by the session.
328    pub fn cancel_subagent(&self, session: &str, run_id: &str) -> anyhow::Result<bool> {
329        let external_chat_id = format!("headless:{}", session.trim());
330        let Some(chat_id) = self
331            .state
332            .db
333            .find_chat_id(HEADLESS_CHANNEL, &external_chat_id)?
334        else {
335            return Ok(false);
336        };
337        Ok(crate::tools::subagents::request_subagent_cancel(
338            &self.state.config,
339            &self.state.db,
340            chat_id,
341            run_id,
342        )?)
343    }
344
345    /// Build a standalone runtime from a loaded product config.
346    ///
347    /// This is the Work/headless bootstrap path. It intentionally initializes
348    /// no chat adapters, Web routes, scheduler, or gateway.
349    pub async fn load(mut config: Config) -> anyhow::Result<Self> {
350        let data_root = config.data_root_dir();
351        let runtime_data_dir = config.runtime_data_dir();
352        let skills_data_dir = config.skills_data_dir();
353        crate::builtin_skills::ensure_builtin_skills(Path::new(&skills_data_dir))?;
354
355        let db = Database::new(&runtime_data_dir)?;
356        let memory = MemoryManager::new(&runtime_data_dir);
357        let skills = SkillManager::from_skills_and_runtime(&skills_data_dir, &runtime_data_dir)
358            .with_config_verification(&config);
359        let mcp_manager = crate::mcp::McpManager::from_config_paths(
360            &crate::mcp::collect_config_paths(&data_root),
361            config.mcp_request_timeout_secs(),
362        )
363        .await;
364
365        config.data_dir = runtime_data_dir;
366        config.skills_dir = Some(skills_data_dir);
367        Ok(Self::new(config, db, memory, skills, mcp_manager))
368    }
369
370    pub fn new(
371        config: Config,
372        db: Database,
373        memory: MemoryManager,
374        skills: SkillManager,
375        mcp_manager: crate::mcp::McpManager,
376    ) -> Self {
377        let db = Arc::new(db);
378        let llm = llm::create_provider(&config);
379        let embedding = embedding::create_provider(&config);
380        let channel_registry = Arc::new(ChannelRegistry::new());
381
382        let memory_backend = Arc::new(MemoryBackend::new(
383            db.clone(),
384            MemoryMcpClient::discover(&mcp_manager),
385            &config.data_dir,
386        ));
387        let mut tools = ToolRegistry::new(
388            &config,
389            channel_registry.clone(),
390            db.clone(),
391            memory_backend.clone(),
392        );
393        tools.register_mcp_tools(&mcp_manager);
394
395        let chat_turn_queue = Arc::new(crate::chat_turn_queue::ChatTurnQueue::new(
396            config.chat_turn_queue_max_pending,
397        ));
398        let (skill_review_queue, _skill_review_worker) =
399            crate::skill_review::build_skill_review_channel();
400        let state = Arc::new(AppState {
401            config: config.clone(),
402            channel_registry,
403            db: db.clone(),
404            memory: Arc::new(memory),
405            skills: Arc::new(skills),
406            hooks: Arc::new(HookManager::from_config(&config).with_db(db.clone())),
407            llm: Arc::from(llm),
408            llm_provider_overrides: Arc::new(RwLock::new(HashMap::new())),
409            llm_model_overrides: Arc::new(RwLock::new(HashMap::new())),
410            embedding,
411            memory_backend,
412            tools: Arc::new(tools),
413            chat_turn_queue,
414            skill_review_queue,
415            metric_exporter: None,
416            trace_exporter: None,
417            log_exporter: None,
418        });
419        Self { state }
420    }
421
422    /// Build the public embeddable Runtime over this configured Agent Engine.
423    pub fn into_embedded_runtime(
424        self,
425        caller_channel: impl Into<String>,
426        max_concurrent_runs: usize,
427    ) -> Result<crate::internal::runtime::Runtime, crate::internal::runtime::RuntimeBuildError>
428    {
429        crate::internal::runtime::Runtime::builder()
430            .executor(HeadlessRunExecutor::new(self, caller_channel))
431            .max_concurrent_runs(max_concurrent_runs)
432            .build()
433    }
434
435    pub async fn run(
436        &self,
437        request: HeadlessRunRequest,
438        event_tx: Option<mpsc::UnboundedSender<RuntimeEventEnvelope>>,
439    ) -> anyhow::Result<HeadlessRunResult> {
440        self.run_with_profile(request, AgentProfile::default(), event_tx)
441            .await
442    }
443
444    pub async fn run_with_profile(
445        &self,
446        request: HeadlessRunRequest,
447        profile: AgentProfile,
448        event_tx: Option<mpsc::UnboundedSender<RuntimeEventEnvelope>>,
449    ) -> anyhow::Result<HeadlessRunResult> {
450        let session_name = request
451            .session
452            .clone()
453            .unwrap_or_else(|| "default".to_string());
454        let chat_id = self.resolve_session_chat_id(&session_name).await?;
455        let caller_channel = request.caller_channel;
456
457        let stored = StoredMessage {
458            id: uuid::Uuid::new_v4().to_string(),
459            chat_id,
460            sender_name: request.sender_name,
461            content: request.prompt,
462            is_from_bot: false,
463            timestamp: chrono::Utc::now().to_rfc3339(),
464        };
465        let _ = call_blocking(self.state.db.clone(), move |db| db.store_message(&stored)).await;
466
467        let run_id = request
468            .run_id
469            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
470        let (raw_event_tx, bridge) = match event_tx {
471            Some(envelope_tx) => {
472                let (raw_tx, raw_rx) = mpsc::unbounded_channel();
473                let bridge = spawn_event_envelope_bridge(run_id.clone(), raw_rx, envelope_tx);
474                (Some(raw_tx), Some(bridge))
475            }
476            None => (None, None),
477        };
478        let response = process_with_agent_profile_guarded_mode(
479            &self.state,
480            AgentRequestContext {
481                caller_channel: &caller_channel,
482                chat_id,
483                chat_type: "private",
484            },
485            None,
486            None,
487            raw_event_tx.as_ref(),
488            None,
489            false,
490            None,
491            profile,
492        )
493        .await;
494        drop(raw_event_tx);
495        if let Some(bridge) = bridge {
496            bridge.await?;
497        }
498        let response = response?;
499
500        let bot_msg = StoredMessage {
501            id: uuid::Uuid::new_v4().to_string(),
502            chat_id,
503            sender_name: "microclaw".to_string(),
504            content: response.clone(),
505            is_from_bot: true,
506            timestamp: chrono::Utc::now().to_rfc3339(),
507        };
508        let _ = call_blocking(self.state.db.clone(), move |db| db.store_message(&bot_msg)).await;
509
510        Ok(HeadlessRunResult {
511            run_id,
512            chat_id,
513            response,
514        })
515    }
516
517    /// Cancel active work for a named headless session.
518    ///
519    /// This delegates to the same run-control registry used by chat, Web, and
520    /// scheduler adapters, so callers stop the real Agent Engine rather than
521    /// merely hiding its output.
522    pub async fn cancel_session(&self, session: &str) -> anyhow::Result<usize> {
523        self.cancel_session_for_channel(HEADLESS_CHANNEL, session)
524            .await
525    }
526
527    pub async fn cancel_work_session(&self, session: &str) -> anyhow::Result<usize> {
528        self.cancel_session_for_channel(WORK_CHANNEL, session).await
529    }
530
531    async fn cancel_session_for_channel(
532        &self,
533        channel: &str,
534        session: &str,
535    ) -> anyhow::Result<usize> {
536        let chat_id = self.resolve_session_chat_id(session).await?;
537        Ok(crate::run_control::abort_runs(channel, chat_id).await)
538    }
539
540    /// Add a user update to an active named session.
541    ///
542    /// The shared Agent Engine drains this queue at its normal tool/end-turn
543    /// breakpoints. Returning `false` means the run ended before the update
544    /// could be queued; callers must not report it as accepted.
545    pub async fn steer_session(&self, session: &str, content: &str) -> anyhow::Result<bool> {
546        self.steer_session_for_channel(HEADLESS_CHANNEL, session, content)
547            .await
548    }
549
550    pub async fn steer_work_session(&self, session: &str, content: &str) -> anyhow::Result<bool> {
551        self.steer_session_for_channel(WORK_CHANNEL, session, content)
552            .await
553    }
554
555    async fn steer_session_for_channel(
556        &self,
557        channel: &str,
558        session: &str,
559        content: &str,
560    ) -> anyhow::Result<bool> {
561        let content = content.trim();
562        if content.is_empty() {
563            anyhow::bail!("steering update must not be empty");
564        }
565        let chat_id = self.resolve_session_chat_id(session).await?;
566        let message_id = uuid::Uuid::new_v4().to_string();
567        let timestamp = chrono::Utc::now().to_rfc3339();
568        let pending = PendingMessage {
569            sender_name: "work".into(),
570            content: content.to_string(),
571            message_id: message_id.clone(),
572            timestamp: timestamp.clone(),
573        };
574        if !self
575            .state
576            .chat_turn_queue
577            .enqueue_if_busy(channel, chat_id, pending)
578            .await
579        {
580            return Ok(false);
581        }
582
583        let stored = StoredMessage {
584            id: message_id,
585            chat_id,
586            sender_name: "work".into(),
587            content: content.to_string(),
588            is_from_bot: false,
589            timestamp,
590        };
591        call_blocking(self.state.db.clone(), move |db| db.store_message(&stored)).await?;
592        Ok(true)
593    }
594
595    async fn resolve_session_chat_id(&self, session: &str) -> anyhow::Result<i64> {
596        let external_chat_id = format!("headless:{session}");
597        let title = format!("headless-{session}");
598        Ok(call_blocking(self.state.db.clone(), move |db| {
599            db.resolve_or_create_chat_id(
600                HEADLESS_CHANNEL,
601                &external_chat_id,
602                Some(&title),
603                "headless",
604            )
605        })
606        .await?)
607    }
608}
609
610fn spawn_event_envelope_bridge(
611    run_id: String,
612    mut raw_rx: mpsc::UnboundedReceiver<AgentEvent>,
613    envelope_tx: mpsc::UnboundedSender<RuntimeEventEnvelope>,
614) -> tokio::task::JoinHandle<()> {
615    tokio::spawn(async move {
616        let mut sequence = 0_u64;
617        while let Some(event) = raw_rx.recv().await {
618            sequence = sequence.saturating_add(1);
619            if envelope_tx
620                .send(RuntimeEventEnvelope::new(&run_id, sequence, event))
621                .is_err()
622            {
623                break;
624            }
625        }
626    })
627}
628
629#[allow(clippy::too_many_arguments)]
630pub async fn run_once(
631    config: Config,
632    db: Database,
633    memory: MemoryManager,
634    skills: SkillManager,
635    mcp_manager: crate::mcp::McpManager,
636    prompt: String,
637    session: Option<String>,
638    json: bool,
639) -> anyhow::Result<i32> {
640    let runtime = HeadlessRuntime::new(config, db, memory, skills, mcp_manager)
641        .into_embedded_runtime(HEADLESS_CHANNEL, 1)?;
642    let mut request = RunRequest::new(prompt);
643    request.session_id = session.map(SessionId::new);
644    let result = runtime
645        .agent(AgentProfile {
646            name: "MicroClaw CLI".into(),
647            ..AgentProfile::default()
648        })
649        .run(request)
650        .result()
651        .await;
652
653    match result {
654        Ok(result) => {
655            let chat_id = result
656                .metadata
657                .get("chat_id")
658                .and_then(serde_json::Value::as_i64);
659            if json {
660                println!(
661                    "{}",
662                    serde_json::json!({
663                        "ok": true,
664                        "response": result.final_text,
665                        "chat_id": chat_id,
666                        "run_id": result.run_id.to_string(),
667                    })
668                );
669            } else {
670                println!("{}", result.final_text);
671            }
672            Ok(0)
673        }
674        Err(e) => {
675            if json {
676                println!(
677                    "{}",
678                    serde_json::json!({"ok": false, "error": e.to_string()})
679                );
680            } else {
681                eprintln!("Error: {e}");
682            }
683            Ok(1)
684        }
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::config::WorkingDirIsolation;
692    use crate::llm::LlmProvider;
693    use microclaw_core::error::MicroClawError;
694    use microclaw_core::llm_types::{
695        Message, MessagesResponse, ResponseContentBlock, ToolDefinition,
696    };
697    use microclaw_core::runtime_event::RuntimeEvent;
698
699    static RUNTIME_TEST_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
700        std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
701
702    struct FakeLlm;
703
704    struct SlowLlm;
705
706    struct CapturingLlm {
707        system_prompt: Arc<std::sync::Mutex<String>>,
708    }
709
710    struct ToolCapturingLlm {
711        tools: Arc<std::sync::Mutex<Vec<String>>>,
712    }
713
714    struct SteeringLlm {
715        calls: Arc<std::sync::atomic::AtomicUsize>,
716        first_call_started: Arc<tokio::sync::Notify>,
717        saw_update: Arc<std::sync::atomic::AtomicBool>,
718    }
719
720    #[async_trait::async_trait]
721    impl LlmProvider for FakeLlm {
722        async fn send_message(
723            &self,
724            _system: &str,
725            _messages: Vec<Message>,
726            _tools: Option<Vec<ToolDefinition>>,
727        ) -> Result<MessagesResponse, MicroClawError> {
728            Ok(MessagesResponse {
729                content: vec![ResponseContentBlock::Text {
730                    text: "real agent loop response".into(),
731                }],
732                stop_reason: Some("end_turn".into()),
733                usage: None,
734            })
735        }
736    }
737
738    #[async_trait::async_trait]
739    impl LlmProvider for SlowLlm {
740        async fn send_message(
741            &self,
742            _system: &str,
743            _messages: Vec<Message>,
744            _tools: Option<Vec<ToolDefinition>>,
745        ) -> Result<MessagesResponse, MicroClawError> {
746            tokio::time::sleep(std::time::Duration::from_secs(30)).await;
747            unreachable!("the cancellation test must stop the pending model call")
748        }
749    }
750
751    #[async_trait::async_trait]
752    impl LlmProvider for CapturingLlm {
753        async fn send_message(
754            &self,
755            system: &str,
756            _messages: Vec<Message>,
757            _tools: Option<Vec<ToolDefinition>>,
758        ) -> Result<MessagesResponse, MicroClawError> {
759            *self.system_prompt.lock().unwrap() = system.to_string();
760            Ok(MessagesResponse {
761                content: vec![ResponseContentBlock::Text {
762                    text: "profile applied".into(),
763                }],
764                stop_reason: Some("end_turn".into()),
765                usage: None,
766            })
767        }
768    }
769
770    #[async_trait::async_trait]
771    impl LlmProvider for ToolCapturingLlm {
772        async fn send_message(
773            &self,
774            _system: &str,
775            _messages: Vec<Message>,
776            tools: Option<Vec<ToolDefinition>>,
777        ) -> Result<MessagesResponse, MicroClawError> {
778            *self.tools.lock().unwrap() = tools
779                .unwrap_or_default()
780                .into_iter()
781                .map(|tool| tool.name)
782                .collect();
783            Ok(MessagesResponse {
784                content: vec![ResponseContentBlock::Text {
785                    text: "policy applied".into(),
786                }],
787                stop_reason: Some("end_turn".into()),
788                usage: None,
789            })
790        }
791    }
792
793    #[async_trait::async_trait]
794    impl LlmProvider for SteeringLlm {
795        async fn send_message(
796            &self,
797            _system: &str,
798            messages: Vec<Message>,
799            _tools: Option<Vec<ToolDefinition>>,
800        ) -> Result<MessagesResponse, MicroClawError> {
801            let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
802            if call == 0 {
803                self.first_call_started.notify_one();
804                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
805                return Ok(MessagesResponse {
806                    content: vec![ResponseContentBlock::ToolUse {
807                        id: "todo-read-1".into(),
808                        name: "todo_read".into(),
809                        input: serde_json::json!({}),
810                        thought_signature: None,
811                    }],
812                    stop_reason: Some("tool_use".into()),
813                    usage: None,
814                });
815            }
816            let rendered = format!("{messages:?}");
817            self.saw_update.store(
818                rendered.contains("Focus on the parser first"),
819                std::sync::atomic::Ordering::SeqCst,
820            );
821            Ok(MessagesResponse {
822                content: vec![ResponseContentBlock::Text {
823                    text: "steering applied".into(),
824                }],
825                stop_reason: Some("end_turn".into()),
826                usage: None,
827            })
828        }
829    }
830
831    fn runtime_with_fake_llm(base: &Path) -> HeadlessRuntime {
832        runtime_with_llm(base, Box::new(FakeLlm))
833    }
834
835    fn runtime_with_llm(base: &Path, llm: Box<dyn LlmProvider>) -> HeadlessRuntime {
836        let runtime_dir = base.join("runtime");
837        std::fs::create_dir_all(&runtime_dir).unwrap();
838        let mut config = Config::test_defaults();
839        config.data_dir = runtime_dir.display().to_string();
840        config.working_dir = base.display().to_string();
841        config.working_dir_isolation = WorkingDirIsolation::Shared;
842        let runtime_dir = runtime_dir.display().to_string();
843        let db = Arc::new(Database::new(&runtime_dir).unwrap());
844        let memory_backend = Arc::new(MemoryBackend::local_only(db.clone()));
845        let channel_registry = Arc::new(ChannelRegistry::new());
846        HeadlessRuntime {
847            state: Arc::new(AppState {
848                config: config.clone(),
849                channel_registry: channel_registry.clone(),
850                db: db.clone(),
851                memory: Arc::new(MemoryManager::new(&runtime_dir)),
852                skills: Arc::new(SkillManager::from_skills_dir(&config.skills_data_dir())),
853                hooks: Arc::new(HookManager::from_config(&config)),
854                llm: Arc::from(llm),
855                llm_provider_overrides: Arc::new(RwLock::new(HashMap::new())),
856                llm_model_overrides: Arc::new(RwLock::new(HashMap::new())),
857                embedding: None,
858                memory_backend: memory_backend.clone(),
859                tools: Arc::new(ToolRegistry::new(
860                    &config,
861                    channel_registry,
862                    db,
863                    memory_backend,
864                )),
865                chat_turn_queue: Arc::new(crate::chat_turn_queue::ChatTurnQueue::new(20)),
866                skill_review_queue: crate::skill_review::build_skill_review_channel().0,
867                metric_exporter: None,
868                trace_exporter: None,
869                log_exporter: None,
870            }),
871        }
872    }
873
874    #[tokio::test]
875    async fn event_bridge_adds_stable_run_id_and_monotonic_sequence() {
876        let (raw_tx, raw_rx) = mpsc::unbounded_channel();
877        let (envelope_tx, mut envelope_rx) = mpsc::unbounded_channel();
878        let bridge = spawn_event_envelope_bridge("work-run-1".into(), raw_rx, envelope_tx);
879
880        raw_tx
881            .send(RuntimeEvent::Iteration { iteration: 1 })
882            .unwrap();
883        raw_tx
884            .send(RuntimeEvent::FinalResponse {
885                text: "done".into(),
886            })
887            .unwrap();
888        drop(raw_tx);
889        bridge.await.unwrap();
890
891        let first = envelope_rx.recv().await.unwrap();
892        let second = envelope_rx.recv().await.unwrap();
893        assert_eq!(first.run_id, "work-run-1");
894        assert_eq!(first.sequence, 1);
895        assert_eq!(second.run_id, "work-run-1");
896        assert_eq!(second.sequence, 2);
897        assert!(envelope_rx.recv().await.is_none());
898    }
899
900    #[tokio::test]
901    async fn reusable_runtime_executes_real_agent_loop_and_emits_envelopes() {
902        let _guard = RUNTIME_TEST_LOCK.lock().await;
903        let directory = tempfile::tempdir().unwrap();
904        let runtime = runtime_with_fake_llm(directory.path());
905        let (event_tx, mut event_rx) = mpsc::unbounded_channel();
906
907        let result = runtime
908            .run(
909                HeadlessRunRequest::work(
910                    "answer through the shared loop".into(),
911                    Some("work-test".into()),
912                    "work-run-test".into(),
913                ),
914                Some(event_tx),
915            )
916            .await
917            .unwrap();
918        let mut events = Vec::new();
919        while let Some(event) = event_rx.recv().await {
920            events.push(event);
921        }
922
923        assert_eq!(result.run_id, "work-run-test");
924        assert_eq!(result.response, "real agent loop response");
925        assert!(!events.is_empty());
926        assert!(events.iter().enumerate().all(|(index, envelope)| {
927            envelope.run_id == "work-run-test" && envelope.sequence == index as u64 + 1
928        }));
929        assert!(events.iter().any(|envelope| matches!(
930            envelope.event,
931            RuntimeEvent::FinalResponse { ref text } if text == "real agent loop response"
932        )));
933    }
934
935    #[tokio::test]
936    async fn durable_subagents_are_observable_and_cancellable_through_headless_runtime() {
937        use crate::storage::db::subagents::CreateSubagentRunParams;
938
939        let _guard = RUNTIME_TEST_LOCK.lock().await;
940        let directory = tempfile::tempdir().unwrap();
941        let runtime = runtime_with_fake_llm(directory.path());
942        let chat_id = runtime
943            .resolve_session_chat_id("sdk-session")
944            .await
945            .unwrap();
946        runtime
947            .state
948            .db
949            .create_subagent_run(CreateSubagentRunParams {
950                run_id: "delegated-1",
951                parent_run_id: Some("main-1"),
952                depth: 1,
953                token_budget: 10_000,
954                chat_id,
955                caller_channel: HEADLESS_CHANNEL,
956                task: "inspect the parser",
957                context: "",
958                provider: "test",
959                model: "test",
960                label: Some("parser review"),
961            })
962            .unwrap();
963
964        let tasks = runtime.subagent_runs("sdk-session", 20).unwrap();
965        assert_eq!(tasks.len(), 1);
966        assert_eq!(tasks[0].parent_run_id.as_deref(), Some("main-1"));
967        assert_eq!(tasks[0].depth, 1);
968        assert_eq!(tasks[0].label.as_deref(), Some("parser review"));
969        assert!(runtime
970            .cancel_subagent("sdk-session", "delegated-1")
971            .unwrap());
972        assert!(runtime.subagent_runs("sdk-session", 20).unwrap()[0].cancel_requested);
973    }
974
975    #[tokio::test]
976    async fn embedded_agent_profile_applies_prompt_and_selected_skills() {
977        let _guard = RUNTIME_TEST_LOCK.lock().await;
978        let directory = tempfile::tempdir().unwrap();
979        let captured = Arc::new(std::sync::Mutex::new(String::new()));
980        let headless = runtime_with_llm(
981            directory.path(),
982            Box::new(CapturingLlm {
983                system_prompt: captured.clone(),
984            }),
985        );
986        let skill_dir = headless.state.skills.skills_dir().join("rust-review");
987        std::fs::create_dir_all(&skill_dir).unwrap();
988        std::fs::write(
989            skill_dir.join("SKILL.md"),
990            "---\nname: rust-review\ndescription: Review Rust\n---\nCHECK_AGENT_SKILL_BODY\n",
991        )
992        .unwrap();
993        let runtime = headless.into_embedded_runtime(WORK_CHANNEL, 1).unwrap();
994        let profile = AgentProfile {
995            name: "reviewer".into(),
996            system_prompt: Some("CHECK_AGENT_PROFILE_PROMPT".into()),
997            skills: vec!["rust-review".into()],
998            ..AgentProfile::default()
999        };
1000
1001        let result = runtime
1002            .agent(profile)
1003            .run(RunRequest::new("review this crate"))
1004            .result()
1005            .await
1006            .unwrap();
1007
1008        assert_eq!(result.final_text, "profile applied");
1009        let system = captured.lock().unwrap();
1010        assert!(system.contains("CHECK_AGENT_PROFILE_PROMPT"), "{system}");
1011        assert!(system.contains("CHECK_AGENT_SKILL_BODY"), "{system}");
1012    }
1013
1014    #[tokio::test]
1015    async fn embedded_read_only_profile_hides_mutating_tools() {
1016        let _guard = RUNTIME_TEST_LOCK.lock().await;
1017        let directory = tempfile::tempdir().unwrap();
1018        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1019        let runtime = runtime_with_llm(
1020            directory.path(),
1021            Box::new(ToolCapturingLlm {
1022                tools: captured.clone(),
1023            }),
1024        )
1025        .into_embedded_runtime(WORK_CHANNEL, 1)
1026        .unwrap();
1027        let profile = AgentProfile {
1028            name: "reviewer".into(),
1029            tool_policy: microclaw_core::run_protocol::ToolPolicy::ReadOnly,
1030            ..AgentProfile::default()
1031        };
1032
1033        runtime
1034            .agent(profile)
1035            .run(RunRequest::new("inspect this project"))
1036            .result()
1037            .await
1038            .unwrap();
1039
1040        let tools = captured.lock().unwrap();
1041        assert!(tools.iter().any(|name| name == "read_file"), "{tools:?}");
1042        assert!(!tools.iter().any(|name| name == "write_file"), "{tools:?}");
1043        assert!(!tools.iter().any(|name| name == "bash"), "{tools:?}");
1044    }
1045
1046    #[tokio::test]
1047    async fn cancelling_a_headless_session_stops_the_real_agent_loop() {
1048        let _guard = RUNTIME_TEST_LOCK.lock().await;
1049        let directory = tempfile::tempdir().unwrap();
1050        let runtime = Arc::new(runtime_with_llm(directory.path(), Box::new(SlowLlm)));
1051        let (event_tx, mut event_rx) = mpsc::unbounded_channel();
1052        let running_runtime = runtime.clone();
1053        let run = tokio::spawn(async move {
1054            running_runtime
1055                .run(
1056                    HeadlessRunRequest::work(
1057                        "wait for cancellation".into(),
1058                        Some("cancel-test".into()),
1059                        "cancel-run-test".into(),
1060                    ),
1061                    Some(event_tx),
1062                )
1063                .await
1064        });
1065
1066        tokio::time::timeout(std::time::Duration::from_secs(5), async {
1067            loop {
1068                if runtime.cancel_work_session("cancel-test").await.unwrap() > 0 {
1069                    break;
1070                }
1071                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1072            }
1073        })
1074        .await
1075        .expect("run should register for cancellation");
1076
1077        let result = tokio::time::timeout(std::time::Duration::from_secs(5), run)
1078            .await
1079            .expect("cancelled run should finish promptly")
1080            .unwrap()
1081            .unwrap();
1082        assert_eq!(result.response, crate::run_control::STOPPED_TEXT);
1083
1084        let mut cancelled = false;
1085        while let Some(envelope) = event_rx.recv().await {
1086            cancelled |= matches!(envelope.event, RuntimeEvent::Cancelled { .. });
1087        }
1088        assert!(cancelled);
1089    }
1090
1091    #[tokio::test]
1092    async fn steering_uses_the_active_agent_turn_queue() {
1093        let _guard = RUNTIME_TEST_LOCK.lock().await;
1094        let directory = tempfile::tempdir().unwrap();
1095        let runtime = runtime_with_fake_llm(directory.path());
1096        let chat_id = runtime.resolve_session_chat_id("steer-test").await.unwrap();
1097        let turn = runtime
1098            .state
1099            .chat_turn_queue
1100            .acquire(HEADLESS_CHANNEL, chat_id)
1101            .await
1102            .unwrap();
1103
1104        assert!(runtime
1105            .steer_session("steer-test", "Focus on the parser first")
1106            .await
1107            .unwrap());
1108        let pending = runtime
1109            .state
1110            .chat_turn_queue
1111            .drain_pending(HEADLESS_CHANNEL, chat_id)
1112            .await;
1113        assert_eq!(pending.len(), 1);
1114        assert_eq!(pending[0].sender_name, "work");
1115        assert_eq!(pending[0].content, "Focus on the parser first");
1116        drop(turn);
1117
1118        assert!(!runtime
1119            .steer_session("steer-test", "This run is already idle")
1120            .await
1121            .unwrap());
1122    }
1123
1124    #[tokio::test]
1125    async fn steering_reaches_the_real_agent_loop_after_a_tool_breakpoint() {
1126        let _guard = RUNTIME_TEST_LOCK.lock().await;
1127        let directory = tempfile::tempdir().unwrap();
1128        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1129        let first_call_started = Arc::new(tokio::sync::Notify::new());
1130        let saw_update = Arc::new(std::sync::atomic::AtomicBool::new(false));
1131        let runtime = Arc::new(runtime_with_llm(
1132            directory.path(),
1133            Box::new(SteeringLlm {
1134                calls: calls.clone(),
1135                first_call_started: first_call_started.clone(),
1136                saw_update: saw_update.clone(),
1137            }),
1138        ));
1139        let running_runtime = runtime.clone();
1140        let run = tokio::spawn(async move {
1141            running_runtime
1142                .run(
1143                    HeadlessRunRequest::work(
1144                        "Start with the whole task".into(),
1145                        Some("live-steer".into()),
1146                        "live-steer-run".into(),
1147                    ),
1148                    None,
1149                )
1150                .await
1151        });
1152
1153        tokio::time::timeout(
1154            std::time::Duration::from_secs(5),
1155            first_call_started.notified(),
1156        )
1157        .await
1158        .expect("first model call should start");
1159        assert!(runtime
1160            .steer_work_session("live-steer", "Focus on the parser first")
1161            .await
1162            .unwrap());
1163        let result = run.await.unwrap().unwrap();
1164
1165        assert_eq!(result.response, "steering applied");
1166        assert!(calls.load(std::sync::atomic::Ordering::SeqCst) >= 2);
1167        assert!(saw_update.load(std::sync::atomic::Ordering::SeqCst));
1168    }
1169}