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::plugin::{HookManager, LifecycleEvent, PluginRegistry};
27use crate::providers::{ChatMessage, ChatRequest, Provider as UnthinkclawProvider};
28use crate::tools::{Tool as UnthinkclawTool, ToolResult as UnthinkclawToolResult, ToolSpec};
29
30#[derive(Clone, Default)]
38pub struct ToolHookContext {
39 hooks: Vec<Arc<dyn ToolHook>>,
40 plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
41 hook_manager: Option<Arc<HookManager>>,
42 stream: Option<AgentStreamTx>,
43}
44
45impl ToolHookContext {
46 pub fn new(
47 hooks: Vec<Arc<dyn ToolHook>>,
48 plugins: Option<Arc<tokio::sync::RwLock<PluginRegistry>>>,
49 ) -> Self {
50 Self {
51 hooks,
52 plugins,
53 hook_manager: None,
54 stream: None,
55 }
56 }
57
58 pub fn with_hook_manager(mut self, hook_manager: Arc<HookManager>) -> Self {
61 self.hook_manager = Some(hook_manager);
62 self
63 }
64
65 pub fn with_stream(mut self, stream: Option<AgentStreamTx>) -> Self {
68 self.stream = stream;
69 self
70 }
71
72 async fn emit_lifecycle(&self, event: LifecycleEvent) {
73 if let Some(manager) = &self.hook_manager {
74 manager.emit(&event).await;
75 }
76 }
77
78 pub async fn check_pre_tool(&self, name: &str, arguments: &str) -> HookDecision {
80 if let Some(plugins) = &self.plugins {
81 let registry = plugins.read().await;
82 if let HookDecision::Block(reason) = registry.check_pre_tool(name, arguments).await {
83 return HookDecision::Block(format!("Blocked by plugin: {reason}"));
84 }
85 }
86 match run_pre_hooks(&self.hooks, name, arguments).await {
87 HookDecision::Block(reason) => {
88 HookDecision::Block(format!("Blocked by policy: {reason}"))
89 }
90 HookDecision::Allow => HookDecision::Allow,
91 }
92 }
93
94 pub async fn notify_post_tool(
96 &self,
97 name: &str,
98 arguments: &str,
99 result: &UnthinkclawToolResult,
100 ) {
101 run_post_hooks(&self.hooks, name, arguments, result).await;
102 self.emit_lifecycle(LifecycleEvent::AfterToolCall(
103 name.to_string(),
104 arguments.to_string(),
105 result.clone(),
106 ))
107 .await;
108 if let Some(plugins) = &self.plugins {
109 let registry = plugins.read().await;
110 registry.notify_post_tool(name, arguments, result).await;
111 }
112 }
113}
114
115pub async fn execute_tool_with_hooks(
121 ctx: &ToolHookContext,
122 name: &str,
123 arguments: &str,
124 tool: Option<&Arc<dyn UnthinkclawTool>>,
125) -> UnthinkclawToolResult {
126 ctx.emit_lifecycle(LifecycleEvent::BeforeToolCall(
127 name.to_string(),
128 arguments.to_string(),
129 ))
130 .await;
131 emit(
132 &ctx.stream,
133 AgentStreamEvent::ToolStart {
134 name: name.to_string(),
135 hint: crate::agent::loop_runner::extract_tool_hint(name, arguments),
136 },
137 );
138
139 let started = std::time::Instant::now();
140 let result = match ctx.check_pre_tool(name, arguments).await {
141 HookDecision::Block(reason) => {
142 tracing::info!("blocked '{}': {}", name, reason);
143 UnthinkclawToolResult::error(reason)
144 }
145 HookDecision::Allow => match tool {
146 Some(tool) => match tool.execute(arguments).await {
147 Ok(result) => result,
148 Err(e) => UnthinkclawToolResult::error(crate::redaction::redact_text(&format!(
149 "Tool error: {e}"
150 ))),
151 },
152 None => UnthinkclawToolResult::error(format!("Unknown tool: {name}")),
153 },
154 };
155
156 ctx.notify_post_tool(name, arguments, &result).await;
157
158 emit(
159 &ctx.stream,
160 AgentStreamEvent::ToolEnd {
161 name: name.to_string(),
162 ok: !result.is_error,
163 elapsed_secs: started.elapsed().as_secs(),
164 },
165 );
166
167 result
168}
169
170pub fn chat_message_to_rx4(msg: &ChatMessage) -> Message {
174 let role = match msg.role.as_str() {
175 "system" => Role::System,
176 "user" => Role::User,
177 "assistant" | "assistant_tool_use" => Role::Assistant,
178 "tool_result" => Role::Tool,
179 _ => Role::User,
180 };
181 Message {
182 role,
183 content: msg.content.clone(),
184 tool_call_id: msg.tool_use_id.clone(),
185 tool_calls: Vec::new(),
186 }
187}
188
189pub fn rx4_message_to_chat(msg: &Message) -> ChatMessage {
191 let role = match msg.role {
192 Role::System => "system",
193 Role::User => "user",
194 Role::Assistant => "assistant",
195 Role::Tool => "tool_result",
196 };
197 ChatMessage {
198 role: role.to_string(),
199 content: msg.content.clone(),
200 tool_use_id: msg.tool_call_id.clone(),
201 }
202}
203
204pub struct RotaryProviderAdapter {
214 inner: Arc<dyn UnthinkclawProvider>,
215 id: String,
216 name: String,
217}
218
219impl RotaryProviderAdapter {
220 pub fn new(provider: Arc<dyn UnthinkclawProvider>) -> Self {
221 let id = provider.name().to_string();
222 let name = format!("apollo-{}", provider.name());
223 Self {
224 inner: provider,
225 id,
226 name,
227 }
228 }
229}
230
231#[async_trait::async_trait]
232impl Rx4Provider for RotaryProviderAdapter {
233 fn id(&self) -> &str {
234 &self.id
235 }
236
237 fn name(&self) -> &str {
238 &self.name
239 }
240
241 async fn stream(
242 &self,
243 messages: &[Message],
244 system: &Option<String>,
245 model: &str,
246 tools: &[serde_json::Value],
247 _reasoning_effort: Option<&str>,
248 ) -> Result<rx4::provider::StreamResult, Rx4ProviderError> {
249 let mut chat_messages: Vec<ChatMessage> = Vec::new();
251
252 if let Some(sys) = system {
254 chat_messages.push(ChatMessage::system(sys));
255 }
256
257 for msg in messages {
258 chat_messages.push(rx4_message_to_chat(msg));
259 }
260
261 let tool_specs: Vec<ToolSpec> = tools
263 .iter()
264 .filter_map(|t| {
265 let name = t.get("name")?.as_str()?.to_string();
266 let description = t
267 .get("description")
268 .and_then(|d| d.as_str())
269 .unwrap_or("")
270 .to_string();
271 let parameters = t
272 .get("parameters")
273 .cloned()
274 .unwrap_or(serde_json::Value::Null);
275 Some(ToolSpec {
276 name,
277 description,
278 parameters,
279 })
280 })
281 .collect();
282
283 let tool_refs: &[ToolSpec] = if tool_specs.is_empty() {
284 &[]
285 } else {
286 &tool_specs
289 };
290
291 let request = ChatRequest {
292 messages: &chat_messages,
293 tools: if tool_refs.is_empty() {
294 None
295 } else {
296 Some(tool_refs)
297 },
298 model,
299 temperature: 0.7,
300 max_tokens: Some(8192),
301 };
302
303 let response = self
304 .inner
305 .chat(&request)
306 .await
307 .map_err(|e| Rx4ProviderError::Api(e.to_string()))?;
308
309 let text = response.text.unwrap_or_default();
311 let tool_calls = response.tool_calls;
312
313 let events: Vec<Result<StreamEvent, Rx4ProviderError>> = {
315 let mut evs = Vec::new();
316 if !text.is_empty() {
317 evs.push(Ok(StreamEvent::Delta(text)));
318 }
319 for tc in tool_calls {
320 evs.push(Ok(StreamEvent::ToolCall(rx4::ToolCall {
321 id: tc.id,
322 name: tc.name,
323 arguments: tc.arguments,
324 })));
325 }
326 evs.push(Ok(StreamEvent::Done));
327 evs
328 };
329
330 use futures_util::stream;
332 Ok(Box::new(Box::pin(stream::iter(events))))
333 }
334}
335
336pub fn register_apollo_tools(
348 registry: &mut rx4::ToolRegistry,
349 tools: &[Arc<dyn UnthinkclawTool>],
350 hook_ctx: &ToolHookContext,
351) {
352 use rx4::guardrails::classify_tool;
353 use rx4::{ToolDefinition, ToolEffect, ToolExecuteBox};
354
355 for tool in tools {
356 let spec = tool.spec();
357 let name = spec.name.clone();
358 let description = spec.description.clone();
359 let parameters_json = serde_json::to_string(&spec.parameters).unwrap_or_default();
360
361 let tool_clone = Arc::clone(tool);
362 let hook_ctx = hook_ctx.clone();
363 let tool_name = name.clone();
364 let execute: ToolExecuteBox = Box::new(move |_ctx, args| {
365 let tool = Arc::clone(&tool_clone);
366 let hook_ctx = hook_ctx.clone();
367 let tool_name = tool_name.clone();
368 Box::pin(async move {
369 let result =
370 execute_tool_with_hooks(&hook_ctx, &tool_name, &args, Some(&tool)).await;
371
372 rx4::ToolResult {
373 id: String::new(),
374 content: result.output,
375 is_error: result.is_error,
376 }
377 })
378 });
379
380 let effect = match classify_tool(&name) {
381 rx4::guardrails::ToolClass::Idempotent => ToolEffect::Read,
382 rx4::guardrails::ToolClass::Mutating => ToolEffect::Write,
383 };
384
385 registry.register(
386 ToolDefinition::new_boxed(name, description, parameters_json, execute)
387 .with_effect(effect),
388 );
389 }
390}
391
392pub struct RotaryBridgeConfig {
396 pub provider: Arc<dyn UnthinkclawProvider>,
397 pub tools: Vec<Arc<dyn UnthinkclawTool>>,
398 pub system_prompt: String,
399 pub model: String,
400 pub workspace: std::path::PathBuf,
401 pub max_tool_iterations: usize,
402 pub auto_compact_after: usize,
405 pub hook_ctx: ToolHookContext,
408}
409
410pub struct RotaryAgentBridge {
422 agent: rx4::Agent,
423 hook_ctx: ToolHookContext,
424 messages: Vec<Message>,
426}
427
428impl RotaryAgentBridge {
429 pub fn new(config: RotaryBridgeConfig) -> Self {
431 let rx4_provider = Arc::new(RotaryProviderAdapter::new(config.provider));
432
433 let mut agent = rx4::Agent::new();
434 agent.set_model(&config.model);
435 agent.set_system_prompt(&config.system_prompt);
436 agent.set_provider(rx4_provider);
437 agent.set_workspace_root(&config.workspace);
438 agent.max_tool_iterations = config.max_tool_iterations;
439 agent.auto_compact_after = config.auto_compact_after;
443
444 agent.set_policy(rx4::Policy::full_access());
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 {
466 agent,
467 hook_ctx: config.hook_ctx,
468 messages: Vec::new(),
469 }
470 }
471
472 pub fn agent(&self) -> &rx4::Agent {
474 &self.agent
475 }
476
477 pub fn agent_mut(&mut self) -> &mut rx4::Agent {
479 &mut self.agent
480 }
481
482 pub fn clear_messages(&mut self) {
484 self.messages.clear();
485 self.agent.clear_messages();
486 }
487
488 pub fn message_count(&self) -> usize {
490 self.messages.len()
491 }
492
493 pub fn set_model(&mut self, model: &str) {
495 self.agent.set_model(model);
496 }
497
498 pub fn set_system_prompt(&mut self, prompt: &str) {
500 self.agent.set_system_prompt(prompt);
501 }
502
503 pub fn set_workspace_root(&mut self, path: &std::path::Path) {
505 self.agent.set_workspace_root(path);
506 }
507
508 pub fn set_scope(&mut self, scope: rx4::Scope) {
510 self.agent.set_scope(scope);
511 }
512
513 pub fn subscribe(&mut self, callback: impl Fn(&rx4::Event) + Send + Sync + 'static) {
515 self.agent.subscribe(callback);
516 }
517
518 pub async fn run_prompt(&mut self, prompt: &str) -> anyhow::Result<String> {
529 let last_response = Arc::new(parking_lot::RwLock::new(String::new()));
531 let last_response_clone = Arc::clone(&last_response);
532
533 self.agent.subscribe(move |event| {
534 if let rx4::Event::MessageEnd {
535 content,
536 role: Role::Assistant,
537 } = event
538 {
539 *last_response_clone.write() = content.clone();
540 }
541 });
542
543 self.agent.prompt(prompt).await?;
544
545 let response = last_response.read().clone();
546 Ok(response)
547 }
548
549 pub async fn run_prompt_with_history(
555 &mut self,
556 prompt: &str,
557 history: &[ChatMessage],
558 ) -> anyhow::Result<String> {
559 self.agent.clear_messages();
561 for msg in history {
562 let rx4_msg = chat_message_to_rx4(msg);
563 self.agent.messages.write().push(rx4_msg);
566 }
567
568 self.run_prompt(prompt).await
569 }
570
571 pub fn register_tools(&mut self, tools: &[Arc<dyn UnthinkclawTool>]) {
573 if let Some(registry) = Arc::get_mut(&mut self.agent.tools) {
574 register_apollo_tools(registry, tools, &self.hook_ctx);
575 } else {
576 tracing::warn!("cannot register rx4 tools while the registry is shared");
577 }
578 }
579
580 pub fn list_tools(&self) -> Vec<String> {
582 self.agent
583 .tools
584 .definitions()
585 .iter()
586 .filter_map(|d| {
587 d.get("name")
588 .and_then(|n| n.as_str())
589 .map(|s| s.to_string())
590 })
591 .collect()
592 }
593
594 pub fn compact(&mut self, reason: &str) {
596 self.agent.compact(reason);
597 }
598
599 pub fn messages_handle(&self) -> Arc<parking_lot::RwLock<Vec<Message>>> {
607 self.agent.messages_handle()
608 }
609
610 pub fn enable_skill_engine(&mut self, workspace: &std::path::Path) {
617 let mut engine = build_rx4_skill_engine(workspace);
618 if let Err(error) = engine.load() {
619 tracing::warn!("rx4 skill engine load failed, leaving it unset: {error}");
620 return;
621 }
622 self.agent.set_skill_engine(engine);
623 }
624
625 pub fn enable_graph_memory(&mut self, workspace: &std::path::Path, auto_dream: bool) {
631 self.agent
632 .set_graph_memory(rx4::GraphMemory::from_workspace(workspace));
633 self.agent.enable_auto_dream(auto_dream);
634 }
635}
636
637pub fn build_rx4_skill_engine(workspace: &std::path::Path) -> rx4::SkillEngine {
659 let home = dirs::home_dir().unwrap_or_default();
660
661 let managed_dir = workspace.join(".apollo/skills");
663
664 let mut engine = rx4::SkillEngine::new(managed_dir);
665
666 let openclaw_skills = home.join(".npm-global/lib/node_modules/openclaw/skills");
668 engine.add_extra_dir(openclaw_skills);
669
670 let shared_skills = home.join(".openclaw/workspace/skills");
671 engine.add_extra_dir(shared_skills);
672
673 engine
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 #[test]
681 fn test_chat_message_to_rx4_system() {
682 let msg = ChatMessage::system("hello");
683 let rx4_msg = chat_message_to_rx4(&msg);
684 assert_eq!(rx4_msg.role, Role::System);
685 assert_eq!(rx4_msg.content, "hello");
686 }
687
688 #[test]
689 fn test_chat_message_to_rx4_user() {
690 let msg = ChatMessage::user("test");
691 let rx4_msg = chat_message_to_rx4(&msg);
692 assert_eq!(rx4_msg.role, Role::User);
693 assert_eq!(rx4_msg.content, "test");
694 }
695
696 #[test]
697 fn test_chat_message_to_rx4_tool_result() {
698 let msg = ChatMessage::tool_result("tc_123", "result text");
699 let rx4_msg = chat_message_to_rx4(&msg);
700 assert_eq!(rx4_msg.role, Role::Tool);
701 assert_eq!(rx4_msg.content, "result text");
702 assert_eq!(rx4_msg.tool_call_id.as_deref(), Some("tc_123"));
703 }
704
705 #[test]
706 fn test_rx4_message_to_chat() {
707 let msg = Message::assistant("hello back");
708 let chat_msg = rx4_message_to_chat(&msg);
709 assert_eq!(chat_msg.role, "assistant");
710 assert_eq!(chat_msg.content, "hello back");
711 }
712
713 #[test]
714 fn test_roundtrip_translation() {
715 let original = ChatMessage::user("roundtrip test");
716 let rx4_msg = chat_message_to_rx4(&original);
717 let back = rx4_message_to_chat(&rx4_msg);
718 assert_eq!(back.role, "user");
719 assert_eq!(back.content, "roundtrip test");
720 }
721
722 #[test]
723 fn test_build_rx4_skill_engine() {
724 let tmp = tempfile::tempdir().unwrap();
726 let engine = build_rx4_skill_engine(tmp.path());
727 assert!(
728 engine.skills_dir().exists()
729 || engine.skills_dir() == tmp.path().join(".apollo/skills")
730 );
731 }
732
733 struct RecordingTool {
734 ran: Arc<std::sync::atomic::AtomicBool>,
735 }
736
737 #[async_trait::async_trait]
738 impl UnthinkclawTool for RecordingTool {
739 fn name(&self) -> &str {
740 "exec"
741 }
742
743 fn spec(&self) -> ToolSpec {
744 ToolSpec {
745 name: "exec".to_string(),
746 description: "test tool".to_string(),
747 parameters: serde_json::json!({"type": "object"}),
748 }
749 }
750
751 async fn execute(&self, _arguments: &str) -> anyhow::Result<UnthinkclawToolResult> {
752 self.ran.store(true, std::sync::atomic::Ordering::SeqCst);
753 Ok(UnthinkclawToolResult::success("ran"))
754 }
755 }
756
757 async fn run_exec_through_rx4(hook_ctx: ToolHookContext) -> (rx4::ToolResult, bool) {
758 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
759 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
760 ran: Arc::clone(&ran),
761 });
762 let mut registry = rx4::ToolRegistry::new();
763 register_apollo_tools(&mut registry, &[tool], &hook_ctx);
764
765 let ctx = Arc::new(rx4::ToolContext::new("."));
766 let result = registry
767 .execute("exec", &ctx, r#"{"command":"rm -rf /"}"#)
768 .await
769 .expect("tool registered");
770 (result, ran.load(std::sync::atomic::Ordering::SeqCst))
771 }
772
773 #[tokio::test]
774 async fn rx4_bridge_enforces_blocking_hooks() {
775 let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
776 vec!["exec".to_string()],
777 vec![],
778 ));
779 let (result, ran) = run_exec_through_rx4(ToolHookContext::new(vec![hook], None)).await;
780 assert!(result.is_error, "blocked tool must report an error");
781 assert!(
782 result.content.contains("Blocked by policy"),
783 "unexpected content: {}",
784 result.content
785 );
786 assert!(!ran, "a blocked tool must not execute under rx4");
787 }
788
789 #[tokio::test]
790 async fn rx4_bridge_allows_unblocked_tools() {
791 let (result, ran) = run_exec_through_rx4(ToolHookContext::default()).await;
792 assert!(!result.is_error);
793 assert_eq!(result.content, "ran");
794 assert!(ran);
795 }
796
797 #[tokio::test]
798 async fn rx4_bridge_enforces_plugin_pre_tool_block() {
799 let mut registry = PluginRegistry::new();
800 registry.register_pre_tool_hook(Arc::new(BlockingPluginHook));
801 let ctx = ToolHookContext::new(
802 Vec::new(),
803 Some(Arc::new(tokio::sync::RwLock::new(registry))),
804 );
805 let (result, ran) = run_exec_through_rx4(ctx).await;
806 assert!(result.is_error);
807 assert!(
808 result.content.contains("Blocked by plugin"),
809 "unexpected content: {}",
810 result.content
811 );
812 assert!(!ran);
813 }
814
815 struct BlockingPluginHook;
816
817 #[async_trait::async_trait]
818 impl crate::plugin::PreToolHook for BlockingPluginHook {
819 fn name(&self) -> &str {
820 "blocking-test-hook"
821 }
822
823 async fn before_tool_call(&self, _name: &str, _arguments: &str) -> HookDecision {
824 HookDecision::Block("plugin says no".to_string())
825 }
826 }
827
828 struct RecordingLifecycleHook {
830 seen: Arc<std::sync::Mutex<Vec<String>>>,
831 }
832
833 #[async_trait::async_trait]
834 impl crate::plugin::LifecycleHook for RecordingLifecycleHook {
835 fn name(&self) -> &str {
836 "recording-lifecycle"
837 }
838
839 async fn on_event(&self, event: &LifecycleEvent) -> anyhow::Result<()> {
840 let label = match event {
841 LifecycleEvent::BeforeToolCall(name, _) => format!("before:{name}"),
842 LifecycleEvent::AfterToolCall(name, _, _) => format!("after:{name}"),
843 other => format!("other:{other:?}"),
844 };
845 self.seen.lock().unwrap().push(label);
846 Ok(())
847 }
848 }
849
850 fn stream_labels(
851 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
852 ) -> Vec<String> {
853 let mut labels = Vec::new();
854 while let Ok(event) = rx.try_recv() {
855 labels.push(match event {
856 AgentStreamEvent::ToolStart { name, .. } => format!("tool_start:{name}"),
857 AgentStreamEvent::ToolEnd { name, ok, .. } => format!("tool_end:{name}:{ok}"),
858 other => format!("other:{other:?}"),
859 });
860 }
861 labels
862 }
863
864 fn recording_context() -> (
866 ToolHookContext,
867 Arc<std::sync::Mutex<Vec<String>>>,
868 tokio::sync::mpsc::UnboundedReceiver<AgentStreamEvent>,
869 ) {
870 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
871 let mut manager = HookManager::new();
872 manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
873 seen: Arc::clone(&seen),
874 }));
875 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
876 let ctx = ToolHookContext::default()
877 .with_hook_manager(Arc::new(manager))
878 .with_stream(Some(tx));
879 (ctx, seen, rx)
880 }
881
882 #[tokio::test]
888 async fn both_engines_emit_the_same_hooks_and_events() {
889 let args = r#"{"command":"ls"}"#;
890
891 let (ctx, rx4_seen, mut rx4_stream) = recording_context();
893 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
894 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
895 ran: Arc::clone(&ran),
896 });
897 let mut registry = rx4::ToolRegistry::new();
898 register_apollo_tools(&mut registry, &[Arc::clone(&tool)], &ctx);
899 let tool_ctx = Arc::new(rx4::ToolContext::new("."));
900 registry
901 .execute("exec", &tool_ctx, args)
902 .await
903 .expect("tool registered");
904 let rx4_events = rx4_seen.lock().unwrap().clone();
905 let rx4_stream_events = stream_labels(&mut rx4_stream);
906
907 let (ctx, legacy_seen, mut legacy_stream) = recording_context();
909 execute_tool_with_hooks(&ctx, "exec", args, Some(&tool)).await;
910 let legacy_events = legacy_seen.lock().unwrap().clone();
911 let legacy_stream_events = stream_labels(&mut legacy_stream);
912
913 assert_eq!(
914 rx4_events, legacy_events,
915 "the paths disagree on lifecycle hooks"
916 );
917 assert_eq!(
918 rx4_stream_events, legacy_stream_events,
919 "the paths disagree on stream events"
920 );
921 assert_eq!(legacy_events, vec!["before:exec", "after:exec"]);
922 assert_eq!(
923 legacy_stream_events,
924 vec!["tool_start:exec", "tool_end:exec:true"]
925 );
926 }
927
928 #[tokio::test]
929 async fn a_blocked_tool_still_reports_start_and_end() {
930 let hook: Arc<dyn ToolHook> = Arc::new(crate::agent::hooks::PermissionHook::new(
931 vec!["exec".to_string()],
932 vec![],
933 ));
934 let (ctx, seen, mut stream) = recording_context();
935 let ctx = ToolHookContext::new(vec![hook], None)
936 .with_hook_manager(Arc::new({
937 let mut manager = HookManager::new();
938 manager.register_lifecycle(Arc::new(RecordingLifecycleHook {
939 seen: Arc::clone(&seen),
940 }));
941 manager
942 }))
943 .with_stream(ctx.stream.clone());
944 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
945 let tool: Arc<dyn UnthinkclawTool> = Arc::new(RecordingTool {
946 ran: Arc::clone(&ran),
947 });
948 let result = execute_tool_with_hooks(&ctx, "exec", "{}", Some(&tool)).await;
949 assert!(result.is_error);
950 assert!(!ran.load(std::sync::atomic::Ordering::SeqCst));
951 assert_eq!(
952 stream_labels(&mut stream),
953 vec!["tool_start:exec", "tool_end:exec:false"],
954 "a blocked call must still open and close its progress line"
955 );
956 assert_eq!(
957 seen.lock().unwrap().clone(),
958 vec!["before:exec", "after:exec"]
959 );
960 }
961}