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