1use std::sync::Arc;
21
22use rx4::provider::{
23 Message, Provider as Rx4Provider, ProviderError as Rx4ProviderError, Role, StreamEvent,
24};
25
26use crate::agent::hooks::{run_post_hooks, run_pre_hooks, HookDecision, ToolHook};
27use crate::agent::stream::{emit, AgentStreamEvent, AgentStreamTx};
28use crate::plugin::{HookManager, LifecycleEvent, PluginRegistry};
29use crate::providers::{ChatMessage, ChatRequest, Provider as UnthinkclawProvider};
30use crate::tools::{Tool as UnthinkclawTool, ToolResult as UnthinkclawToolResult, ToolSpec};
31
32#[derive(Clone, Default)]
42pub struct ToolHookContext {
43 hooks: Vec<Arc<dyn ToolHook>>,
44 plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
45 hook_manager: Option<Arc<HookManager>>,
46 stream: Option<AgentStreamTx>,
47}
48
49impl ToolHookContext {
50 pub fn new(
51 hooks: Vec<Arc<dyn ToolHook>>,
52 plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
53 ) -> Self {
54 Self {
55 hooks,
56 plugins,
57 hook_manager: None,
58 stream: None,
59 }
60 }
61
62 pub fn with_hook_manager(mut self, hook_manager: Arc<HookManager>) -> Self {
65 self.hook_manager = Some(hook_manager);
66 self
67 }
68
69 pub fn with_stream(mut self, stream: Option<AgentStreamTx>) -> Self {
72 self.stream = stream;
73 self
74 }
75
76 async fn emit_lifecycle(&self, event: LifecycleEvent) {
77 if let Some(manager) = &self.hook_manager {
78 manager.emit(&event).await;
79 }
80 }
81
82 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
84 if let Some(plugins) = &self.plugins {
85 let registry = plugins.read().await;
86 if let HookDecision::Block(reason) = registry.check_pre_tool(name, arguments).await {
87 return HookDecision::Block(format!("Blocked by plugin: {reason}"));
88 }
89 }
90 match run_pre_hooks(&self.hooks, name, arguments).await {
91 HookDecision::Block(reason) => {
92 HookDecision::Block(format!("Blocked by policy: {reason}"))
93 }
94 HookDecision::Allow => HookDecision::Allow,
95 }
96 }
97
98 pub async fn notify_post_tool(
100 &self,
101 name: &str,
102 arguments: &str,
103 result: &UnthinkclawToolResult,
104 ) {
105 run_post_hooks(&self.hooks, name, arguments, result).await;
106 self.emit_lifecycle(LifecycleEvent::AfterToolCall(
107 name.to_string(),
108 arguments.to_string(),
109 result.clone(),
110 ))
111 .await;
112 if let Some(plugins) = &self.plugins {
113 let registry = plugins.read().await;
114 registry.notify_post_tool(name, arguments, result).await;
115 }
116 }
117}
118
119pub async fn execute_tool_with_hooks(
125 ctx: &ToolHookContext,
126 name: &str,
127 arguments: &str,
128 tool: Option<&Arc<dyn UnthinkclawTool>>,
129) -> UnthinkclawToolResult {
130 ctx.emit_lifecycle(LifecycleEvent::BeforeToolCall(
131 name.to_string(),
132 arguments.to_string(),
133 ))
134 .await;
135 emit(
136 &ctx.stream,
137 AgentStreamEvent::ToolStart {
138 name: name.to_string(),
139 hint: crate::agent::loop_runner::extract_tool_hint(name, arguments),
140 },
141 );
142
143 let started = std::time::Instant::now();
144 let result = match ctx.check_pre_tool(name, arguments).await {
145 HookDecision::Block(reason) => {
146 tracing::info!("blocked '{}': {}", name, reason);
147 UnthinkclawToolResult::error(reason)
148 }
149 HookDecision::Allow => match tool {
150 Some(tool) => match tool.execute(arguments).await {
151 Ok(result) => result,
152 Err(e) => UnthinkclawToolResult::error(crate::redaction::redact_text(&format!(
153 "Tool error: {e}"
154 ))),
155 },
156 None => UnthinkclawToolResult::error(format!("Unknown tool: {name}")),
157 },
158 };
159
160 ctx.notify_post_tool(name, arguments, &result).await;
161
162 emit(
163 &ctx.stream,
164 AgentStreamEvent::ToolEnd {
165 name: name.to_string(),
166 ok: !result.is_error,
167 elapsed_secs: started.elapsed().as_secs(),
168 },
169 );
170
171 result
172}
173
174pub fn chat_message_to_rx4(msg: &ChatMessage) -> Message {
178 let role = match msg.role.as_str() {
179 "system" => Role::System,
180 "user" => Role::User,
181 "assistant" | "assistant_tool_use" => Role::Assistant,
182 "tool_result" => Role::Tool,
183 _ => Role::User,
184 };
185 Message {
186 role,
187 content: msg.content.clone(),
188 tool_call_id: msg.tool_use_id.clone(),
189 tool_calls: Vec::new(),
190 }
191}
192
193pub fn rx4_message_to_chat(msg: &Message) -> ChatMessage {
195 let role = match msg.role {
196 Role::System => "system",
197 Role::User => "user",
198 Role::Assistant => "assistant",
199 Role::Tool => "tool_result",
200 };
201 ChatMessage {
202 role: role.to_string(),
203 content: msg.content.clone(),
204 tool_use_id: msg.tool_call_id.clone(),
205 }
206}
207
208pub fn chat_messages_to_rx4(messages: &[ChatMessage]) -> Vec<Message> {
210 messages.iter().map(chat_message_to_rx4).collect()
211}
212
213pub fn tool_specs_to_rx4_json(specs: &[ToolSpec]) -> Vec<serde_json::Value> {
215 specs
216 .iter()
217 .map(|s| {
218 serde_json::json!({
219 "name": s.name,
220 "description": s.description,
221 "parameters": s.parameters,
222 })
223 })
224 .collect()
225}
226
227pub struct RotaryProviderAdapter {
237 inner: Arc<dyn UnthinkclawProvider>,
238 id: String,
239 name: String,
240}
241
242impl RotaryProviderAdapter {
243 pub fn new(provider: Arc<dyn UnthinkclawProvider>) -> Self {
244 let id = provider.name().to_string();
245 let name = format!("apollo-{}", provider.name());
246 Self {
247 inner: provider,
248 id,
249 name,
250 }
251 }
252}
253
254#[async_trait::async_trait]
255impl Rx4Provider for RotaryProviderAdapter {
256 fn id(&self) -> &str {
257 &self.id
258 }
259
260 fn name(&self) -> &str {
261 &self.name
262 }
263
264 async fn stream(
265 &self,
266 messages: &[Message],
267 system: &Option<String>,
268 model: &str,
269 tools: &[serde_json::Value],
270 _reasoning_effort: Option<&str>,
271 ) -> Result<rx4::provider::StreamResult, Rx4ProviderError> {
272 let mut chat_messages: Vec<ChatMessage> = Vec::new();
274
275 if let Some(sys) = system {
277 chat_messages.push(ChatMessage::system(sys));
278 }
279
280 for msg in messages {
281 chat_messages.push(rx4_message_to_chat(msg));
282 }
283
284 let tool_specs: Vec<ToolSpec> = tools
286 .iter()
287 .filter_map(|t| {
288 let name = t.get("name")?.as_str()?.to_string();
289 let description = t
290 .get("description")
291 .and_then(|d| d.as_str())
292 .unwrap_or("")
293 .to_string();
294 let parameters = t
295 .get("parameters")
296 .cloned()
297 .unwrap_or(serde_json::Value::Null);
298 Some(ToolSpec {
299 name,
300 description,
301 parameters,
302 })
303 })
304 .collect();
305
306 let tool_refs: &[ToolSpec] = if tool_specs.is_empty() {
307 &[]
308 } else {
309 &tool_specs
312 };
313
314 let request = ChatRequest {
315 messages: &chat_messages,
316 tools: if tool_refs.is_empty() {
317 None
318 } else {
319 Some(tool_refs)
320 },
321 model,
322 temperature: 0.7,
323 max_tokens: Some(8192),
324 };
325
326 let response = self
327 .inner
328 .chat(&request)
329 .await
330 .map_err(|e| Rx4ProviderError::Api(e.to_string()))?;
331
332 let text = response.text.unwrap_or_default();
334 let tool_calls = response.tool_calls;
335
336 let events: Vec<Result<StreamEvent, Rx4ProviderError>> = {
338 let mut evs = Vec::new();
339 if !text.is_empty() {
340 evs.push(Ok(StreamEvent::Delta(text)));
341 }
342 for tc in tool_calls {
343 evs.push(Ok(StreamEvent::ToolCall(rx4::ToolCall {
344 id: tc.id,
345 name: tc.name,
346 arguments: tc.arguments,
347 })));
348 }
349 evs.push(Ok(StreamEvent::Done));
350 evs
351 };
352
353 use futures_util::stream;
355 Ok(Box::new(Box::pin(stream::iter(events))))
356 }
357}
358
359pub fn register_apollo_tools(
371 registry: &mut rx4::ToolRegistry,
372 tools: &[Arc<dyn UnthinkclawTool>],
373 hook_ctx: &ToolHookContext,
374) {
375 use rx4::guardrails::classify_tool;
376 use rx4::{ToolDefinition, ToolEffect, ToolExecuteBox};
377
378 for tool in tools {
379 let spec = tool.spec();
380 let name = spec.name.clone();
381 let description = spec.description.clone();
382 let parameters_json = serde_json::to_string(&spec.parameters).unwrap_or_default();
383
384 let tool_clone = Arc::clone(tool);
385 let hook_ctx = hook_ctx.clone();
386 let tool_name = name.clone();
387 let execute: ToolExecuteBox = Box::new(move |_ctx, args| {
388 let tool = Arc::clone(&tool_clone);
389 let hook_ctx = hook_ctx.clone();
390 let tool_name = tool_name.clone();
391 Box::pin(async move {
392 let result =
393 execute_tool_with_hooks(&hook_ctx, &tool_name, &args, Some(&tool)).await;
394
395 rx4::ToolResult {
396 id: String::new(),
397 content: result.output,
398 is_error: result.is_error,
399 }
400 })
401 });
402
403 let effect = match classify_tool(&name) {
404 rx4::guardrails::ToolClass::Idempotent => ToolEffect::Read,
405 rx4::guardrails::ToolClass::Mutating => ToolEffect::Write,
406 };
407
408 registry.register(
409 ToolDefinition::new_boxed(name, description, parameters_json, execute)
410 .with_effect(effect),
411 );
412 }
413}
414
415pub struct RotaryBridgeConfig {
419 pub provider: Arc<dyn UnthinkclawProvider>,
420 pub tools: Vec<Arc<dyn UnthinkclawTool>>,
421 pub system_prompt: String,
422 pub model: String,
423 pub workspace: std::path::PathBuf,
424 pub max_tool_iterations: usize,
425 pub hook_ctx: ToolHookContext,
428}
429
430pub struct RotaryAgentBridge {
442 agent: rx4::Agent,
443 hook_ctx: ToolHookContext,
444 messages: Vec<Message>,
446}
447
448impl RotaryAgentBridge {
449 pub fn new(config: RotaryBridgeConfig) -> Self {
451 let rx4_provider = Arc::new(RotaryProviderAdapter::new(config.provider));
452
453 let mut agent = rx4::Agent::new();
454 agent.set_model(&config.model);
455 agent.set_system_prompt(&config.system_prompt);
456 agent.set_provider(rx4_provider);
457 agent.set_workspace_root(&config.workspace);
458 agent.max_tool_iterations = config.max_tool_iterations;
459
460 let mut tool_registry = rx4::ToolRegistry::new();
462 register_apollo_tools(&mut tool_registry, &config.tools, &config.hook_ctx);
463 agent.tools = Arc::new(tool_registry);
464
465 Self {
470 agent,
471 hook_ctx: config.hook_ctx,
472 messages: Vec::new(),
473 }
474 }
475
476 pub fn agent(&self) -> &rx4::Agent {
478 &self.agent
479 }
480
481 pub fn agent_mut(&mut self) -> &mut rx4::Agent {
483 &mut self.agent
484 }
485
486 pub fn clear_messages(&mut self) {
488 self.messages.clear();
489 self.agent.clear_messages();
490 }
491
492 pub fn message_count(&self) -> usize {
494 self.messages.len()
495 }
496
497 pub fn set_model(&mut self, model: &str) {
499 self.agent.set_model(model);
500 }
501
502 pub fn set_system_prompt(&mut self, prompt: &str) {
504 self.agent.set_system_prompt(prompt);
505 }
506
507 pub fn set_workspace_root(&mut self, path: &std::path::Path) {
509 self.agent.set_workspace_root(path);
510 }
511
512 pub fn set_scope(&mut self, scope: rx4::Scope) {
514 self.agent.set_scope(scope);
515 }
516
517 pub fn subscribe(&mut self, callback: impl Fn(&rx4::Event) + Send + Sync + 'static) {
519 self.agent.subscribe(callback);
520 }
521
522 pub async fn run_prompt(&mut self, prompt: &str) -> anyhow::Result<String> {
533 let last_response = Arc::new(parking_lot::RwLock::new(String::new()));
535 let last_response_clone = Arc::clone(&last_response);
536
537 self.agent.subscribe(move |event| {
538 if let rx4::Event::MessageEnd {
539 content,
540 role: Role::Assistant,
541 } = event
542 {
543 *last_response_clone.write() = content.clone();
544 }
545 });
546
547 self.agent.prompt(prompt).await?;
548
549 let response = last_response.read().clone();
550 Ok(response)
551 }
552
553 pub async fn run_prompt_with_history(
559 &mut self,
560 prompt: &str,
561 history: &[ChatMessage],
562 ) -> anyhow::Result<String> {
563 self.agent.clear_messages();
565 for msg in history {
566 let rx4_msg = chat_message_to_rx4(msg);
567 self.agent.messages.write().push(rx4_msg);
570 }
571
572 self.run_prompt(prompt).await
573 }
574
575 pub fn register_tools(&mut self, tools: &[Arc<dyn UnthinkclawTool>]) {
577 if let Some(registry) = Arc::get_mut(&mut self.agent.tools) {
578 register_apollo_tools(registry, tools, &self.hook_ctx);
579 } else {
580 tracing::warn!("cannot register rx4 tools while the registry is shared");
581 }
582 }
583
584 pub fn list_tools(&self) -> Vec<String> {
586 self.agent
587 .tools
588 .definitions()
589 .iter()
590 .filter_map(|d| {
591 d.get("name")
592 .and_then(|n| n.as_str())
593 .map(|s| s.to_string())
594 })
595 .collect()
596 }
597
598 pub fn compact(&mut self, reason: &str) {
600 self.agent.compact(reason);
601 }
602}
603
604pub fn build_rx4_skill_engine(workspace: &std::path::Path) -> rx4::SkillEngine {
626 let home = dirs::home_dir().unwrap_or_default();
627
628 let managed_dir = workspace.join(".apollo/skills");
630
631 let mut engine = rx4::SkillEngine::new(managed_dir);
632
633 let openclaw_skills = home.join(".npm-global/lib/node_modules/openclaw/skills");
635 engine.add_extra_dir(openclaw_skills);
636
637 let shared_skills = home.join(".openclaw/workspace/skills");
638 engine.add_extra_dir(shared_skills);
639
640 engine
641}
642
643pub fn match_skill_via_rx4(
654 engine: &rx4::SkillEngine,
655 user_message: &str,
656) -> Option<(String, String)> {
657 let results = engine.search(user_message);
658 if results.is_empty() {
659 return None;
660 }
661
662 let skill = results[0];
664 Some((skill.name.clone(), skill.instructions.clone()))
665}
666
667pub fn discover_skills_via_rx4(workspace: &std::path::Path) -> Vec<crate::skills::Skill> {
675 let mut engine = build_rx4_skill_engine(workspace);
676 if engine.load().is_err() {
677 tracing::warn!("rx4 SkillEngine load failed, returning empty skill list");
678 return Vec::new();
679 }
680
681 engine
682 .list()
683 .into_iter()
684 .map(|rx4_skill| {
685 let location = engine.skills_dir().join(format!("{}.json", rx4_skill.id));
686 crate::skills::Skill {
687 name: rx4_skill.name.clone(),
688 description: rx4_skill.description.clone(),
689 location,
690 }
691 })
692 .collect()
693}
694
695pub struct RotaryMemoryBridge {
714 graph: rx4::GraphMemory,
715 extractor: rx4::ConversationExtractor,
716 graph_path: Option<std::path::PathBuf>,
718}
719
720impl RotaryMemoryBridge {
721 pub fn new(workspace: &std::path::Path) -> Self {
723 let graph = rx4::GraphMemory::from_workspace(workspace);
724 let graph_path = workspace.join(".apollo/graph_memory.json");
725 Self {
726 graph,
727 extractor: rx4::ConversationExtractor::new(),
728 graph_path: Some(graph_path),
729 }
730 }
731
732 pub fn empty() -> Self {
734 Self {
735 graph: rx4::GraphMemory::new(),
736 extractor: rx4::ConversationExtractor::new(),
737 graph_path: None,
738 }
739 }
740
741 pub fn extract_conversation(
748 &mut self,
749 conversation: &[rx4::graph_memory::ConversationTurn],
750 ) -> rx4::ExtractionResult {
751 let result = self.extractor.extract(conversation);
752
753 for node in &result.nodes {
755 self.graph.add_node(node.clone());
756 }
757 for edge in &result.edges {
758 let _ = self.graph.add_edge(edge.clone());
759 }
760
761 result
762 }
763
764 pub fn search(&self, query: &str) -> Vec<&rx4::GraphMemoryNode> {
766 self.graph.search(query)
767 }
768
769 pub fn pagerank(&self) -> Vec<(String, f64)> {
772 self.graph.pagerank()
773 }
774
775 pub fn stats(&self) -> rx4::graph_memory::GraphStats {
777 self.graph.stats()
778 }
779
780 pub fn save(&self) -> Result<(), rx4::GraphMemoryError> {
782 if let Some(path) = &self.graph_path {
783 if let Some(parent) = path.parent() {
784 let _ = std::fs::create_dir_all(parent);
785 }
786 self.graph.save(path)?;
787 tracing::debug!("graph memory saved to {:?}", self.graph_path);
788 }
789 Ok(())
790 }
791
792 pub fn load(&mut self) -> Result<(), rx4::GraphMemoryError> {
794 if let Some(path) = &self.graph_path {
795 if path.exists() {
796 self.graph = rx4::GraphMemory::load(path)?;
797 tracing::debug!("graph memory loaded from {:?}", self.graph_path);
798 }
799 }
800 Ok(())
801 }
802
803 pub fn graph(&self) -> &rx4::GraphMemory {
805 &self.graph
806 }
807
808 pub fn graph_mut(&mut self) -> &mut rx4::GraphMemory {
810 &mut self.graph
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 #[test]
819 fn test_chat_message_to_rx4_system() {
820 let msg = ChatMessage::system("hello");
821 let rx4_msg = chat_message_to_rx4(&msg);
822 assert_eq!(rx4_msg.role, Role::System);
823 assert_eq!(rx4_msg.content, "hello");
824 }
825
826 #[test]
827 fn test_chat_message_to_rx4_user() {
828 let msg = ChatMessage::user("test");
829 let rx4_msg = chat_message_to_rx4(&msg);
830 assert_eq!(rx4_msg.role, Role::User);
831 assert_eq!(rx4_msg.content, "test");
832 }
833
834 #[test]
835 fn test_chat_message_to_rx4_tool_result() {
836 let msg = ChatMessage::tool_result("tc_123", "result text");
837 let rx4_msg = chat_message_to_rx4(&msg);
838 assert_eq!(rx4_msg.role, Role::Tool);
839 assert_eq!(rx4_msg.content, "result text");
840 assert_eq!(rx4_msg.tool_call_id.as_deref(), Some("tc_123"));
841 }
842
843 #[test]
844 fn test_rx4_message_to_chat() {
845 let msg = Message::assistant("hello back");
846 let chat_msg = rx4_message_to_chat(&msg);
847 assert_eq!(chat_msg.role, "assistant");
848 assert_eq!(chat_msg.content, "hello back");
849 }
850
851 #[test]
852 fn test_tool_specs_to_rx4_json() {
853 let specs = vec![ToolSpec {
854 name: "shell".to_string(),
855 description: "Run shell commands".to_string(),
856 parameters: serde_json::json!({"type": "object"}),
857 }];
858 let json = tool_specs_to_rx4_json(&specs);
859 assert_eq!(json.len(), 1);
860 assert_eq!(json[0]["name"], "shell");
861 }
862
863 #[test]
864 fn test_roundtrip_translation() {
865 let original = ChatMessage::user("roundtrip test");
866 let rx4_msg = chat_message_to_rx4(&original);
867 let back = rx4_message_to_chat(&rx4_msg);
868 assert_eq!(back.role, "user");
869 assert_eq!(back.content, "roundtrip test");
870 }
871
872 #[test]
873 fn test_build_rx4_skill_engine() {
874 let tmp = tempfile::tempdir().unwrap();
876 let engine = build_rx4_skill_engine(tmp.path());
877 assert!(
878 engine.skills_dir().exists()
879 || engine.skills_dir() == tmp.path().join(".apollo/skills")
880 );
881 }
882
883 #[test]
884 fn test_match_skill_via_rx4_empty() {
885 let tmp = tempfile::tempdir().unwrap();
886 let mut engine = build_rx4_skill_engine(tmp.path());
887 let _ = engine.load();
888 let result = match_skill_via_rx4(&engine, "test query");
890 assert!(result.is_none());
891 }
892
893 struct RecordingTool {
894 ran: Arc<std::sync::atomic::AtomicBool>,
895 }
896
897 #[async_trait::async_trait]
898 impl UnthinkclawTool for RecordingTool {
899 fn name(&self) -> &str {
900 "exec"
901 }
902
903 fn spec(&self) -> ToolSpec {
904 ToolSpec {
905 name: "exec".to_string(),
906 description: "test tool".to_string(),
907 parameters: serde_json::json!({"type": "object"}),
908 }
909 }
910
911 async fn execute(&self, _arguments: &str) -> anyhow::Result<UnthinkclawToolResult> {
912 self.ran.store(true, std::sync::atomic::Ordering::SeqCst);
913 Ok(UnthinkclawToolResult::success("ran"))
914 }
915 }
916
917 async fn run_exec_through_rx4(hook_ctx: ToolHookContext) -> (rx4::ToolResult, bool) {
918 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
919 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
920 ran: Arc::clone(&ran),
921 });
922 let mut registry = rx4::ToolRegistry::new();
923 register_apollo_tools(&mut registry, &[tool], &hook_ctx);
924
925 let ctx = Arc::new(rx4::ToolContext::new("."));
926 let result = registry
927 .execute("exec", &ctx, r#"{"command":"rm -rf /"}"#)
928 .await
929 .expect("tool registered");
930 (result, ran.load(std::sync::atomic::Ordering::SeqCst))
931 }
932
933 #[tokio::test]
934 async fn rx4_bridge_enforces_blocking_hooks() {
935 let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
936 vec!["exec".to_string()],
937 vec![],
938 ));
939 let (result, ran) = run_exec_through_rx4(ToolHookContext::new(vec![hook], None)).await;
940 assert!(result.is_error, "blocked tool must report an error");
941 assert!(
942 result.content.contains("Blocked by policy"),
943 "unexpected content: {}",
944 result.content
945 );
946 assert!(!ran, "a blocked tool must not execute under rx4");
947 }
948
949 #[tokio::test]
950 async fn rx4_bridge_allows_unblocked_tools() {
951 let (result, ran) = run_exec_through_rx4(ToolHookContext::default()).await;
952 assert!(!result.is_error);
953 assert_eq!(result.content, "ran");
954 assert!(ran);
955 }
956
957 #[tokio::test]
958 async fn rx4_bridge_enforces_plugin_pre_tool_block() {
959 let mut registry = PluginRegistry::new();
960 registry.register_pre_tool_hook(Arc::new(BlockingPluginHook));
961 let ctx = ToolHookContext::new(
962 Vec::new(),
963 Some(Arc::new(tokio::sync::RwLock::new(registry))),
964 );
965 let (result, ran) = run_exec_through_rx4(ctx).await;
966 assert!(result.is_error);
967 assert!(
968 result.content.contains("Blocked by plugin"),
969 "unexpected content: {}",
970 result.content
971 );
972 assert!(!ran);
973 }
974
975 struct BlockingPluginHook;
976
977 #[async_trait::async_trait]
978 impl crate::plugin::PreToolHook for BlockingPluginHook {
979 fn name(&self) -> &str {
980 "blocking-test-hook"
981 }
982
983 async fn before_tool_call(&self, _name: &str, _arguments: &str) -> HookDecision {
984 HookDecision::Block("plugin says no".to_string())
985 }
986 }
987
988 struct RecordingLifecycleHook {
990 seen: Arc<std::sync::Mutex<Vec<String>>>,
991 }
992
993 #[async_trait::async_trait]
994 impl crate::plugin::LifecycleHook for RecordingLifecycleHook {
995 fn name(&self) -> &str {
996 "recording-lifecycle"
997 }
998
999 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
1000 let label = match event {
1001 LifecycleEvent::BeforeToolCall(name, _) => format!("before:{name}"),
1002 LifecycleEvent::AfterToolCall(name, _, _) => format!("after:{name}"),
1003 other => format!("other:{other:?}"),
1004 };
1005 self.seen.lock().unwrap().push(label);
1006 Ok(())
1007 }
1008 }
1009
1010 fn stream_labels(
1011 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
1012 ) -> Vec<String> {
1013 let mut labels = Vec::new();
1014 while let Ok(event) = rx.try_recv() {
1015 labels.push(match event {
1016 AgentStreamEvent::ToolStart { name, .. } => format!("tool_start:{name}"),
1017 AgentStreamEvent::ToolEnd { name, ok, .. } => format!("tool_end:{name}:{ok}"),
1018 other => format!("other:{other:?}"),
1019 });
1020 }
1021 labels
1022 }
1023
1024 fn recording_context() -> (
1026 ToolHookContext,
1027 Arc<std::sync::Mutex<Vec<String>>>,
1028 tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
1029 ) {
1030 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1031 let mut manager = HookManager::new();
1032 manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
1033 seen: Arc::clone(&seen),
1034 }));
1035 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1036 let ctx = ToolHookContext::default()
1037 .with_hook_manager(Arc::new(manager))
1038 .with_stream(Some(tx));
1039 (ctx, seen, rx)
1040 }
1041
1042 #[tokio::test]
1048 async fn both_engines_emit_the_same_hooks_and_events() {
1049 let args = r#"{"command":"ls"}"#;
1050
1051 let (ctx, rx4_seen, mut rx4_stream) = recording_context();
1053 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
1054 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
1055 ran: Arc::clone(&ran),
1056 });
1057 let mut registry = rx4::ToolRegistry::new();
1058 register_apollo_tools(&mut registry, &[Arc::clone(&tool)], &ctx);
1059 let tool_ctx = Arc::new(rx4::ToolContext::new("."));
1060 registry
1061 .execute("exec", &tool_ctx, args)
1062 .await
1063 .expect("tool registered");
1064 let rx4_events = rx4_seen.lock().unwrap().clone();
1065 let rx4_stream_events = stream_labels(&mut rx4_stream);
1066
1067 let (ctx, legacy_seen, mut legacy_stream) = recording_context();
1069 execute_tool_with_hooks(&ctx, "exec", args, Some(&tool)).await;
1070 let legacy_events = legacy_seen.lock().unwrap().clone();
1071 let legacy_stream_events = stream_labels(&mut legacy_stream);
1072
1073 assert_eq!(
1074 rx4_events, legacy_events,
1075 "the engines disagree on lifecycle hooks"
1076 );
1077 assert_eq!(
1078 rx4_stream_events, legacy_stream_events,
1079 "the engines disagree on stream events"
1080 );
1081 assert_eq!(legacy_events, vec!["before:exec", "after:exec"]);
1082 assert_eq!(
1083 legacy_stream_events,
1084 vec!["tool_start:exec", "tool_end:exec:true"]
1085 );
1086 }
1087
1088 #[tokio::test]
1089 async fn a_blocked_tool_still_reports_start_and_end() {
1090 let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
1091 vec!["exec".to_string()],
1092 vec![],
1093 ));
1094 let (ctx, seen, mut stream) = recording_context();
1095 let ctx = ToolHookContext::new(vec![hook], None)
1096 .with_hook_manager(Arc::new({
1097 let mut manager = HookManager::new();
1098 manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
1099 seen: Arc::clone(&seen),
1100 }));
1101 manager
1102 }))
1103 .with_stream(ctx.stream.clone());
1104 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
1105 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
1106 ran: Arc::clone(&ran),
1107 });
1108 let result = execute_tool_with_hooks(&ctx, "exec", "{}", Some(&tool)).await;
1109 assert!(result.is_error);
1110 assert!(!ran.load(std::sync::atomic::Ordering::SeqCst));
1111 assert_eq!(
1112 stream_labels(&mut stream),
1113 vec!["tool_start:exec", "tool_end:exec:false"],
1114 "a blocked call must still open and close its progress line"
1115 );
1116 assert_eq!(
1117 seen.lock().unwrap().clone(),
1118 vec!["before:exec", "after:exec"]
1119 );
1120 }
1121
1122 #[test]
1123 fn test_rotary_memory_bridge_empty() {
1124 let bridge = RotaryMemoryBridge::empty();
1125 let stats = bridge.stats();
1126 assert_eq!(stats.node_count, 0);
1127 }
1128
1129 #[test]
1130 fn test_rotary_memory_bridge_search_empty() {
1131 let bridge = RotaryMemoryBridge::empty();
1132 let results = bridge.search("test");
1133 assert!(results.is_empty());
1134 }
1135
1136 #[test]
1137 fn test_rotary_memory_bridge_save_load() {
1138 let tmp = tempfile::tempdir().unwrap();
1139 let mut bridge = RotaryMemoryBridge::new(tmp.path());
1140 bridge.save().unwrap();
1142 bridge.load().unwrap();
1143 assert_eq!(bridge.stats().node_count, 0);
1144 }
1145}