1use std::sync::Arc;
19
20use rx4::provider::{
21 Message, Provider as Rx4Provider, ProviderError as Rx4ProviderError, Role, StreamEvent,
22};
23
24use crate::agent::hooks::{run_post_hooks, run_pre_hooks, HookDecision, ToolHook};
25use crate::agent::stream::{emit, AgentStreamEvent, AgentStreamTx};
26use crate::cost::{ContextSnapshot, CostTracker, TokenUsage};
27use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
28use crate::providers::{ChatMessage, ChatRequest, Provider as UnthinkclawProvider};
29use crate::tools::{Tool as UnthinkclawTool, ToolResult as UnthinkclawToolResult, ToolSpec};
30
31#[derive(Clone, Default)]
39pub struct ToolHookContext {
40 hooks: Vec<Arc<dyn ToolHook>>,
41 plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
42 hook_manager: Option<Arc<HookManager>>,
43 stream: Option<AgentStreamTx>,
44}
45
46impl ToolHookContext {
47 pub fn new(
48 hooks: Vec<Arc<dyn ToolHook>>,
49 plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
50 ) -> Self {
51 Self {
52 hooks,
53 plugins,
54 hook_manager: None,
55 stream: None,
56 }
57 }
58
59 pub fn with_hook_manager(mut self, hook_manager: Arc<HookManager>) -> Self {
62 self.hook_manager = Some(hook_manager);
63 self
64 }
65
66 pub fn with_stream(mut self, stream: Option<AgentStreamTx>) -> Self {
69 self.stream = stream;
70 self
71 }
72
73 async fn emit_lifecycle(&self, event: LifecycleEvent) {
74 if let Some(manager) = &self.hook_manager {
75 manager.emit(&event).await;
76 }
77 }
78
79 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
81 if let Some(plugins) = &self.plugins {
82 let registry = plugins.read().await;
83 if let HookDecision::Block(reason) = registry.check_pre_tool(name, arguments).await {
84 return HookDecision::Block(format!("Blocked by plugin: {reason}"));
85 }
86 }
87 match run_pre_hooks(&self.hooks, name, arguments).await {
88 HookDecision::Block(reason) => {
89 HookDecision::Block(format!("Blocked by policy: {reason}"))
90 }
91 HookDecision::Allow => HookDecision::Allow,
92 }
93 }
94
95 pub async fn notify_post_tool(
97 &self,
98 name: &str,
99 arguments: &str,
100 result: &UnthinkclawToolResult,
101 ) {
102 run_post_hooks(&self.hooks, name, arguments, result).await;
103 self.emit_lifecycle(LifecycleEvent::AfterToolCall(
104 name.to_string(),
105 arguments.to_string(),
106 result.clone(),
107 ))
108 .await;
109 if let Some(plugins) = &self.plugins {
110 let registry = plugins.read().await;
111 registry.notify_post_tool(name, arguments, result).await;
112 }
113 }
114}
115
116pub async fn execute_tool_with_hooks(
122 ctx: &ToolHookContext,
123 name: &str,
124 arguments: &str,
125 tool: Option<&Arc<dyn UnthinkclawTool>>,
126) -> UnthinkclawToolResult {
127 ctx.emit_lifecycle(LifecycleEvent::BeforeToolCall(
128 name.to_string(),
129 arguments.to_string(),
130 ))
131 .await;
132 emit(
133 &ctx.stream,
134 AgentStreamEvent::ToolStart {
135 name: name.to_string(),
136 hint: crate::agent::loop_runner::extract_tool_hint(name, arguments),
137 },
138 );
139
140 let started = std::time::Instant::now();
141 let result = match ctx.check_pre_tool(name, arguments).await {
142 HookDecision::Block(reason) => {
143 tracing::info!("blocked '{}': {}", name, reason);
144 UnthinkclawToolResult::error(reason)
145 }
146 HookDecision::Allow => match tool {
147 Some(tool) => match tool.execute(arguments).await {
148 Ok(result) => result,
149 Err(e) => UnthinkclawToolResult::error(crate::redaction::redact_text(&format!(
150 "Tool error: {e}"
151 ))),
152 },
153 None => UnthinkclawToolResult::error(format!("Unknown tool: {name}")),
154 },
155 };
156
157 ctx.notify_post_tool(name, arguments, &result).await;
158
159 emit(
160 &ctx.stream,
161 AgentStreamEvent::ToolEnd {
162 name: name.to_string(),
163 ok: !result.is_error,
164 elapsed_secs: started.elapsed().as_secs(),
165 },
166 );
167
168 result
169}
170
171pub fn chat_message_to_rx4(msg: &ChatMessage) -> Message {
175 let role = match msg.role.as_str() {
176 "system" => Role::System,
177 "user" => Role::User,
178 "assistant" | "assistant_tool_use" => Role::Assistant,
179 "tool_result" => Role::Tool,
180 _ => Role::User,
181 };
182 Message {
183 role,
184 content: msg.content.clone(),
185 tool_call_id: msg.tool_use_id.clone(),
186 tool_calls: Vec::new(),
187 }
188}
189
190pub fn rx4_message_to_chat(msg: &Message) -> ChatMessage {
192 let role = match msg.role {
193 Role::System => "system",
194 Role::User => "user",
195 Role::Assistant => "assistant",
196 Role::Tool => "tool_result",
197 };
198 ChatMessage {
199 role: role.to_string(),
200 content: msg.content.clone(),
201 tool_use_id: msg.tool_call_id.clone(),
202 }
203}
204
205pub struct RotaryProviderAdapter {
215 inner: Arc<dyn UnthinkclawProvider>,
216 id: String,
217 name: String,
218 cost_tracker: Option<Arc<CostTracker>>,
219}
220
221impl RotaryProviderAdapter {
222 pub fn new(
223 provider: Arc<dyn UnthinkclawProvider>,
224 cost_tracker: Option<Arc<CostTracker>>,
225 ) -> Self {
226 let id = provider.name().to_string();
227 let name = format!("apollo-{}", provider.name());
228 Self {
229 inner: provider,
230 id,
231 name,
232 cost_tracker,
233 }
234 }
235}
236
237#[async_trait::async_trait]
238impl Rx4Provider for RotaryProviderAdapter {
239 fn id(&self) -> &str {
240 &self.id
241 }
242
243 fn name(&self) -> &str {
244 &self.name
245 }
246
247 async fn stream(
248 &self,
249 messages: &[Message],
250 system: &Option<String>,
251 model: &str,
252 tools: &[serde_json::Value],
253 _reasoning_effort: Option<&str>,
254 ) -> Result<rx4::provider::StreamResult, Rx4ProviderError> {
255 let mut chat_messages: Vec<ChatMessage> = Vec::new();
257
258 if let Some(sys) = system {
260 chat_messages.push(ChatMessage::system(sys));
261 }
262
263 for msg in messages {
264 chat_messages.push(rx4_message_to_chat(msg));
265 }
266
267 let tool_specs: Vec<ToolSpec> = tools
269 .iter()
270 .filter_map(|t| {
271 let name = t.get("name")?.as_str()?.to_string();
272 let description = t
273 .get("description")
274 .and_then(|d| d.as_str())
275 .unwrap_or("")
276 .to_string();
277 let parameters = t
278 .get("parameters")
279 .cloned()
280 .unwrap_or(serde_json::Value::Null);
281 Some(ToolSpec {
282 name,
283 description,
284 parameters,
285 })
286 })
287 .collect();
288
289 let tool_refs: &[ToolSpec] = if tool_specs.is_empty() {
290 &[]
291 } else {
292 &tool_specs
295 };
296
297 let request = ChatRequest {
298 messages: &chat_messages,
299 tools: if tool_refs.is_empty() {
300 None
301 } else {
302 Some(tool_refs)
303 },
304 model,
305 temperature: 0.7,
306 max_tokens: Some(8192),
307 };
308
309 if let Some(tracker) = &self.cost_tracker {
310 let system_chars = system
311 .as_ref()
312 .map(|value| value.chars().count())
313 .unwrap_or(0);
314 let history_chars = messages
315 .iter()
316 .map(|message| message.content.chars().count())
317 .sum::<usize>();
318 let tool_chars = tools
319 .iter()
320 .map(|tool| {
321 serde_json::to_string(tool)
322 .unwrap_or_default()
323 .chars()
324 .count()
325 })
326 .sum::<usize>();
327 tracker
328 .record_context(ContextSnapshot {
329 system_chars,
330 history_chars,
331 tool_chars,
332 estimated_input_tokens: (system_chars + history_chars + tool_chars).div_ceil(4),
333 })
334 .await;
335 }
336
337 let response = self
338 .inner
339 .chat(&request)
340 .await
341 .map_err(|e| Rx4ProviderError::Api(e.to_string()))?;
342
343 if let (Some(tracker), Some(usage)) = (&self.cost_tracker, response.usage.as_ref()) {
344 let _ = tracker
345 .record(
346 model,
347 TokenUsage {
348 input_tokens: usage.input_tokens as usize,
349 output_tokens: usage.output_tokens as usize,
350 total_tokens: usage.input_tokens as usize + usage.output_tokens as usize,
351 },
352 )
353 .await;
354 }
355
356 let text = response.text.unwrap_or_default();
358 let tool_calls = response.tool_calls;
359
360 let events: Vec<Result<StreamEvent, Rx4ProviderError>> = {
362 let mut evs = Vec::new();
363 if !text.is_empty() {
364 evs.push(Ok(StreamEvent::Delta(text)));
365 }
366 for tc in tool_calls {
367 evs.push(Ok(StreamEvent::ToolCall(rx4::ToolCall {
368 id: tc.id,
369 name: tc.name,
370 arguments: tc.arguments,
371 })));
372 }
373 evs.push(Ok(StreamEvent::Done));
374 evs
375 };
376
377 use futures_util::stream;
379 Ok(Box::new(Box::pin(stream::iter(events))))
380 }
381}
382
383pub fn register_apollo_tools(
395 registry: &mut rx4::ToolRegistry,
396 tools: &[Arc<dyn UnthinkclawTool>],
397 hook_ctx: &ToolHookContext,
398) {
399 use rx4::guardrails::classify_tool;
400 use rx4::{ToolDefinition, ToolEffect, ToolExecuteBox};
401
402 for tool in tools {
403 let spec = tool.spec();
404 let name = spec.name.clone();
405 let description = spec.description.clone();
406 let parameters_json = serde_json::to_string(&spec.parameters).unwrap_or_default();
407
408 let tool_clone = Arc::clone(tool);
409 let hook_ctx = hook_ctx.clone();
410 let tool_name = name.clone();
411 let execute: ToolExecuteBox = Box::new(move |_ctx, args| {
412 let tool = Arc::clone(&tool_clone);
413 let hook_ctx = hook_ctx.clone();
414 let tool_name = tool_name.clone();
415 Box::pin(async move {
416 let result =
417 execute_tool_with_hooks(&hook_ctx, &tool_name, &args, Some(&tool)).await;
418
419 rx4::ToolResult {
420 id: String::new(),
421 content: result.output,
422 is_error: result.is_error,
423 error_kind: None,
424 }
425 })
426 });
427
428 let effect = match classify_tool(&name) {
429 rx4::guardrails::ToolClass::Idempotent => ToolEffect::Read,
430 rx4::guardrails::ToolClass::Mutating => ToolEffect::Write,
431 };
432
433 registry.register(
434 ToolDefinition::new_boxed(name, description, parameters_json, execute)
435 .with_effect(effect),
436 );
437 }
438}
439
440pub struct RotaryBridgeConfig {
444 pub provider: Arc<dyn UnthinkclawProvider>,
445 pub tools: Vec<Arc<dyn UnthinkclawTool>>,
446 pub system_prompt: String,
447 pub model: String,
448 pub workspace: std::path::PathBuf,
449 pub max_tool_iterations: usize,
450 pub auto_compact_after: usize,
453 pub cost_tracker: Option<Arc<CostTracker>>,
455 pub hook_ctx: ToolHookContext,
458}
459
460pub struct RotaryAgentBridge {
472 agent: rx4::Agent,
473 hook_ctx: ToolHookContext,
474 messages: Vec<Message>,
476}
477
478impl RotaryAgentBridge {
479 pub fn new(config: RotaryBridgeConfig) -> Self {
481 let rx4_provider = Arc::new(RotaryProviderAdapter::new(
482 config.provider,
483 config.cost_tracker,
484 ));
485
486 let mut agent = rx4::Agent::new();
487 agent.set_model(&config.model);
488 agent.set_system_prompt(&config.system_prompt);
489 agent.set_provider(rx4_provider);
490 agent.set_workspace_root(&config.workspace);
491 agent.max_tool_iterations = config.max_tool_iterations;
492 agent.auto_compact_after = config.auto_compact_after;
496
497 agent.set_policy(rx4::Policy::full_access());
512
513 let mut tool_registry = rx4::ToolRegistry::new();
515 register_apollo_tools(&mut tool_registry, &config.tools, &config.hook_ctx);
516 agent.tools = Arc::new(tool_registry);
517
518 Self {
519 agent,
520 hook_ctx: config.hook_ctx,
521 messages: Vec::new(),
522 }
523 }
524
525 pub fn agent(&self) -> &rx4::Agent {
527 &self.agent
528 }
529
530 pub fn agent_mut(&mut self) -> &mut rx4::Agent {
532 &mut self.agent
533 }
534
535 pub fn clear_messages(&mut self) {
537 self.messages.clear();
538 self.agent.clear_messages();
539 }
540
541 pub fn message_count(&self) -> usize {
543 self.messages.len()
544 }
545
546 pub fn set_model(&mut self, model: &str) {
548 self.agent.set_model(model);
549 }
550
551 pub fn set_system_prompt(&mut self, prompt: &str) {
553 self.agent.set_system_prompt(prompt);
554 }
555
556 pub fn set_workspace_root(&mut self, path: &std::path::Path) {
558 self.agent.set_workspace_root(path);
559 }
560
561 pub fn set_scope(&mut self, scope: rx4::Scope) {
563 self.agent.set_scope(scope);
564 }
565
566 pub fn subscribe(&mut self, callback: impl Fn(&rx4::Event) + Send + Sync + 'static) {
568 self.agent.subscribe(callback);
569 }
570
571 pub async fn run_prompt(&mut self, prompt: &str) -> anyhow::Result<String> {
582 let last_response = Arc::new(parking_lot::RwLock::new(String::new()));
584 let last_response_clone = Arc::clone(&last_response);
585
586 self.agent.subscribe(move |event| {
587 if let rx4::Event::MessageEnd {
588 content,
589 role: Role::Assistant,
590 } = event
591 {
592 *last_response_clone.write() = content.clone();
593 }
594 });
595
596 self.agent.prompt(prompt).await?;
597
598 let response = last_response.read().clone();
599 Ok(response)
600 }
601
602 pub async fn run_prompt_with_history(
608 &mut self,
609 prompt: &str,
610 history: &[ChatMessage],
611 ) -> anyhow::Result<String> {
612 self.agent.clear_messages();
614 for msg in history {
615 let rx4_msg = chat_message_to_rx4(msg);
616 self.agent.messages.write().push(rx4_msg);
619 }
620
621 self.run_prompt(prompt).await
622 }
623
624 pub fn register_tools(&mut self, tools: &[Arc<dyn UnthinkclawTool>]) {
626 if let Some(registry) = Arc::get_mut(&mut self.agent.tools) {
627 register_apollo_tools(registry, tools, &self.hook_ctx);
628 } else {
629 tracing::warn!("cannot register rx4 tools while the registry is shared");
630 }
631 }
632
633 pub fn list_tools(&self) -> Vec<String> {
635 self.agent
636 .tools
637 .definitions()
638 .iter()
639 .filter_map(|d| {
640 d.get("name")
641 .and_then(|n| n.as_str())
642 .map(|s| s.to_string())
643 })
644 .collect()
645 }
646
647 pub fn compact(&mut self, reason: &str) {
649 self.agent.compact(reason);
650 }
651
652 pub fn messages_handle(&self) -> Arc<parking_lot::RwLock<Vec<Message>>> {
660 self.agent.messages_handle()
661 }
662
663 pub fn enable_skill_engine(&mut self, workspace: &std::path::Path) {
670 let mut engine = build_rx4_skill_engine(workspace);
671 if let Err(error) = engine.load() {
672 tracing::warn!("rx4 skill engine load failed, leaving it unset: {error}");
673 return;
674 }
675 self.agent.set_skill_engine(engine);
676 }
677
678 pub fn enable_graph_memory(&mut self, workspace: &std::path::Path, auto_dream: bool) {
684 self.agent
685 .set_graph_memory(rx4::GraphMemory::from_workspace(workspace));
686 self.agent.enable_auto_dream(auto_dream);
687 }
688}
689
690pub fn build_rx4_skill_engine(workspace: &std::path::Path) -> rx4::SkillEngine {
712 let home = dirs::home_dir().unwrap_or_default();
713
714 let managed_dir = workspace.join(".apollo/skills");
716
717 let mut engine = rx4::SkillEngine::new(managed_dir);
718
719 let openclaw_skills = home.join(".npm-global/lib/node_modules/openclaw/skills");
721 engine.add_extra_dir(openclaw_skills);
722
723 let shared_skills = home.join(".openclaw/workspace/skills");
724 engine.add_extra_dir(shared_skills);
725
726 engine
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn test_chat_message_to_rx4_system() {
735 let msg = ChatMessage::system("hello");
736 let rx4_msg = chat_message_to_rx4(&msg);
737 assert_eq!(rx4_msg.role, Role::System);
738 assert_eq!(rx4_msg.content, "hello");
739 }
740
741 #[test]
742 fn test_chat_message_to_rx4_user() {
743 let msg = ChatMessage::user("test");
744 let rx4_msg = chat_message_to_rx4(&msg);
745 assert_eq!(rx4_msg.role, Role::User);
746 assert_eq!(rx4_msg.content, "test");
747 }
748
749 #[test]
750 fn test_chat_message_to_rx4_tool_result() {
751 let msg = ChatMessage::tool_result("tc_123", "result text");
752 let rx4_msg = chat_message_to_rx4(&msg);
753 assert_eq!(rx4_msg.role, Role::Tool);
754 assert_eq!(rx4_msg.content, "result text");
755 assert_eq!(rx4_msg.tool_call_id.as_deref(), Some("tc_123"));
756 }
757
758 #[test]
759 fn test_rx4_message_to_chat() {
760 let msg = Message::assistant("hello back");
761 let chat_msg = rx4_message_to_chat(&msg);
762 assert_eq!(chat_msg.role, "assistant");
763 assert_eq!(chat_msg.content, "hello back");
764 }
765
766 #[test]
767 fn test_roundtrip_translation() {
768 let original = ChatMessage::user("roundtrip test");
769 let rx4_msg = chat_message_to_rx4(&original);
770 let back = rx4_message_to_chat(&rx4_msg);
771 assert_eq!(back.role, "user");
772 assert_eq!(back.content, "roundtrip test");
773 }
774
775 #[test]
776 fn test_build_rx4_skill_engine() {
777 let tmp = tempfile::tempdir().unwrap();
779 let engine = build_rx4_skill_engine(tmp.path());
780 assert!(
781 engine.skills_dir().exists()
782 || engine.skills_dir() == tmp.path().join(".apollo/skills")
783 );
784 }
785
786 struct RecordingTool {
787 ran: Arc<std::sync::atomic::AtomicBool>,
788 }
789
790 #[async_trait::async_trait]
791 impl UnthinkclawTool for RecordingTool {
792 fn name(&self) -> &str {
793 "exec"
794 }
795
796 fn spec(&self) -> ToolSpec {
797 ToolSpec {
798 name: "exec".to_string(),
799 description: "test tool".to_string(),
800 parameters: serde_json::json!({"type": "object"}),
801 }
802 }
803
804 async fn execute(&self, _arguments: &str) -> anyhow::Result<UnthinkclawToolResult> {
805 self.ran.store(true, std::sync::atomic::Ordering::SeqCst);
806 Ok(UnthinkclawToolResult::success("ran"))
807 }
808 }
809
810 async fn run_exec_through_rx4(hook_ctx: ToolHookContext) -> (rx4::ToolResult, bool) {
811 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
812 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
813 ran: Arc::clone(&ran),
814 });
815 let mut registry = rx4::ToolRegistry::new();
816 register_apollo_tools(&mut registry, &[tool], &hook_ctx);
817
818 let ctx = Arc::new(rx4::ToolContext::new("."));
819 let result = registry
820 .execute("exec", &ctx, r#"{"command":"rm -rf /"}"#)
821 .await
822 .expect("tool registered");
823 (result, ran.load(std::sync::atomic::Ordering::SeqCst))
824 }
825
826 #[tokio::test]
827 async fn rx4_bridge_enforces_blocking_hooks() {
828 let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
829 vec!["exec".to_string()],
830 vec![],
831 ));
832 let (result, ran) = run_exec_through_rx4(ToolHookContext::new(vec![hook], None)).await;
833 assert!(result.is_error, "blocked tool must report an error");
834 assert!(
835 result.content.contains("Blocked by policy"),
836 "unexpected content: {}",
837 result.content
838 );
839 assert!(!ran, "a blocked tool must not execute under rx4");
840 }
841
842 #[tokio::test]
843 async fn rx4_bridge_allows_unblocked_tools() {
844 let (result, ran) = run_exec_through_rx4(ToolHookContext::default()).await;
845 assert!(!result.is_error);
846 assert_eq!(result.content, "ran");
847 assert!(ran);
848 }
849
850 #[tokio::test]
851 async fn rx4_bridge_enforces_plugin_pre_tool_block() {
852 let mut registry = PluginRegistry::new();
853 registry.register_pre_tool_hook(Arc::new(BlockingPluginHook));
854 let ctx = ToolHookContext::new(
855 Vec::new(),
856 Some(Arc::new(tokio::sync::RwLock::new(registry))),
857 );
858 let (result, ran) = run_exec_through_rx4(ctx).await;
859 assert!(result.is_error);
860 assert!(
861 result.content.contains("Blocked by plugin"),
862 "unexpected content: {}",
863 result.content
864 );
865 assert!(!ran);
866 }
867
868 struct BlockingPluginHook;
869
870 #[async_trait::async_trait]
871 impl crate::plugin::PreToolHook for BlockingPluginHook {
872 fn name(&self) -> &str {
873 "blocking-test-hook"
874 }
875
876 async fn before_tool_call(&self, _name: &str, _arguments: &str) -> HookDecision {
877 HookDecision::Block("plugin says no".to_string())
878 }
879 }
880
881 struct RecordingLifecycleHook {
883 seen: Arc<std::sync::Mutex<Vec<String>>>,
884 }
885
886 #[async_trait::async_trait]
887 impl crate::plugin::LifecycleHook for RecordingLifecycleHook {
888 fn name(&self) -> &str {
889 "recording-lifecycle"
890 }
891
892 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
893 let label = match event {
894 LifecycleEvent::BeforeToolCall(name, _) => format!("before:{name}"),
895 LifecycleEvent::AfterToolCall(name, _, _) => format!("after:{name}"),
896 other => format!("other:{other:?}"),
897 };
898 self.seen.lock().unwrap().push(label);
899 Ok(())
900 }
901 }
902
903 fn stream_labels(
904 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
905 ) -> Vec<String> {
906 let mut labels = Vec::new();
907 while let Ok(event) = rx.try_recv() {
908 labels.push(match event {
909 AgentStreamEvent::ToolStart { name, .. } => format!("tool_start:{name}"),
910 AgentStreamEvent::ToolEnd { name, ok, .. } => format!("tool_end:{name}:{ok}"),
911 other => format!("other:{other:?}"),
912 });
913 }
914 labels
915 }
916
917 fn recording_context() -> (
919 ToolHookContext,
920 Arc<std::sync::Mutex<Vec<String>>>,
921 tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
922 ) {
923 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
924 let mut manager = HookManager::new();
925 manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
926 seen: Arc::clone(&seen),
927 }));
928 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
929 let ctx = ToolHookContext::default()
930 .with_hook_manager(Arc::new(manager))
931 .with_stream(Some(tx));
932 (ctx, seen, rx)
933 }
934
935 #[tokio::test]
941 async fn both_engines_emit_the_same_hooks_and_events() {
942 let args = r#"{"command":"ls"}"#;
943
944 let (ctx, rx4_seen, mut rx4_stream) = recording_context();
946 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
947 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
948 ran: Arc::clone(&ran),
949 });
950 let mut registry = rx4::ToolRegistry::new();
951 register_apollo_tools(&mut registry, &[Arc::clone(&tool)], &ctx);
952 let tool_ctx = Arc::new(rx4::ToolContext::new("."));
953 registry
954 .execute("exec", &tool_ctx, args)
955 .await
956 .expect("tool registered");
957 let rx4_events = rx4_seen.lock().unwrap().clone();
958 let rx4_stream_events = stream_labels(&mut rx4_stream);
959
960 let (ctx, legacy_seen, mut legacy_stream) = recording_context();
962 execute_tool_with_hooks(&ctx, "exec", args, Some(&tool)).await;
963 let legacy_events = legacy_seen.lock().unwrap().clone();
964 let legacy_stream_events = stream_labels(&mut legacy_stream);
965
966 assert_eq!(
967 rx4_events, legacy_events,
968 "the paths disagree on lifecycle hooks"
969 );
970 assert_eq!(
971 rx4_stream_events, legacy_stream_events,
972 "the paths disagree on stream events"
973 );
974 assert_eq!(legacy_events, vec!["before:exec", "after:exec"]);
975 assert_eq!(
976 legacy_stream_events,
977 vec!["tool_start:exec", "tool_end:exec:true"]
978 );
979 }
980
981 #[tokio::test]
982 async fn a_blocked_tool_still_reports_start_and_end() {
983 let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
984 vec!["exec".to_string()],
985 vec![],
986 ));
987 let (ctx, seen, mut stream) = recording_context();
988 let ctx = ToolHookContext::new(vec![hook], None)
989 .with_hook_manager(Arc::new({
990 let mut manager = HookManager::new();
991 manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
992 seen: Arc::clone(&seen),
993 }));
994 manager
995 }))
996 .with_stream(ctx.stream.clone());
997 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
998 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
999 ran: Arc::clone(&ran),
1000 });
1001 let result = execute_tool_with_hooks(&ctx, "exec", "{}", Some(&tool)).await;
1002 assert!(result.is_error);
1003 assert!(!ran.load(std::sync::atomic::Ordering::SeqCst));
1004 assert_eq!(
1005 stream_labels(&mut stream),
1006 vec!["tool_start:exec", "tool_end:exec:false"],
1007 "a blocked call must still open and close its progress line"
1008 );
1009 assert_eq!(
1010 seen.lock().unwrap().clone(),
1011 vec!["before:exec", "after:exec"]
1012 );
1013 }
1014}