1use 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#[derive(Clone)]
83pub struct HeadlessRuntime {
84 state: Arc<AppState>,
85}
86
87pub 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 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 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 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 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 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}