Skip to main content

clawft_core/agent/
loop_core.rs

1//! Core agent loop -- message processing pipeline.
2//!
3//! The [`AgentLoop`] is the heart of the clawft agent. It implements the
4//! consume-process-respond cycle ported from Python `nanobot/agent/loop.py`:
5//!
6//! ```text
7//! Inbound Message (from MessageBus)
8//!   |
9//!   v
10//! Session lookup / creation
11//!   |
12//!   v
13//! ContextBuilder.build_messages()
14//!   |
15//!   v
16//! Pipeline execution (Classifier -> Router -> Assembler -> Transport -> Scorer -> Learner)
17//!   |
18//!   v
19//! Tool execution loop (up to max_tool_iterations)
20//!   |  - Extract tool calls from LLM response
21//!   |  - Execute each tool via ToolRegistry
22//!   |  - Append tool results to context
23//!   |  - Re-invoke LLM if stop_reason == ToolUse
24//!   |
25//!   v
26//! Outbound Message (dispatched to MessageBus)
27//! ```
28
29use std::sync::Arc;
30
31use clawft_plugin::CancellationToken;
32use tracing::{debug, error, info, warn};
33
34use clawft_platform::Platform;
35use clawft_types::config::AgentsConfig;
36use clawft_types::error::ClawftError;
37use clawft_types::event::{InboundMessage, OutboundMessage};
38use clawft_types::provider::ContentBlock;
39use clawft_types::routing::AuthContext;
40
41use crate::bus::MessageBus;
42use crate::pipeline::permissions::PermissionResolver;
43use crate::pipeline::traits::{ChatRequest, LlmMessage, PipelineRegistry};
44use crate::session::SessionManager;
45use crate::tools::registry::ToolRegistry;
46
47use super::context::ContextBuilder;
48use super::verification;
49
50// ---------------------------------------------------------------------------
51// Auto-delegation trait
52// ---------------------------------------------------------------------------
53
54/// Trait for pre-LLM automatic delegation routing.
55///
56/// Implementations check whether an inbound message should be routed to
57/// a delegation tool (e.g. `delegate_task`) before the local LLM processes
58/// it. This enables rule-based auto-routing for complex tasks that match
59/// configured patterns (e.g. "swarm", "orchestrate", "deploy").
60///
61/// If [`should_delegate`](AutoDelegation::should_delegate) returns `Some`,
62/// the agent loop invokes the `delegate_task` tool directly, bypassing the
63/// local LLM entirely. If it returns `None`, normal LLM processing proceeds.
64pub trait AutoDelegation: Send + Sync {
65    /// Check whether the message content should be auto-delegated.
66    ///
67    /// Returns `Some(args)` with the JSON arguments for the `delegate_task`
68    /// tool if delegation should happen, or `None` to proceed normally.
69    fn should_delegate(&self, content: &str) -> Option<serde_json::Value>;
70}
71
72/// Maximum size in bytes for a single tool result.
73const MAX_TOOL_RESULT_BYTES: usize = 65_536;
74
75/// System prompt injected for voice-mode sessions.
76///
77/// Instructs the LLM to respond in natural conversational language suitable
78/// for text-to-speech, rather than written/markdown format. The frontend
79/// strips any residual formatting before passing to the TTS engine.
80const VOICE_MODE_PROMPT: &str = "\
81# Voice Mode
82
83You are responding via voice. Your answer will be spoken aloud by a text-to-speech engine.
84
85Rules:
86- Respond in natural, conversational language as if speaking to someone in person.
87- Keep responses to 1-3 sentences for simple questions. Use more for complex topics, but stay concise.
88- Use contractions: say \"it's\", \"you'll\", \"that's\", \"I'd\" instead of formal equivalents.
89- Never use markdown formatting: no bold, italic, headers, bullet lists, numbered lists, or code fences.
90- Never read URLs, file paths, or raw code aloud. Instead, describe what it does or say \"I can show that on screen\".
91- For numbers, use natural speech: \"about seventy-two degrees\" not \"72F\", \"around three hundred\" not \"300\".
92- Use natural transitions instead of lists: \"there are a couple of things\" or \"first... and then...\" rather than enumerating with dashes or numbers.
93- If you need to share code, a table, or structured data, give a brief spoken summary and note that the details are on screen.
94- Do not start with \"Sure!\" or \"Of course!\". Just answer naturally.
95- Do not narrate your actions (\"Let me search for...\"). Just provide the answer.
96- Sound warm and natural, not robotic or formal.";
97
98/// Result from the tool loop, including hallucination counters.
99#[derive(Debug)]
100struct ToolLoopResult {
101    /// The final text response from the LLM.
102    text: String,
103    /// Number of write claims that failed verification (hallucinated).
104    hallucinations: usize,
105    /// Number of write claims that passed verification.
106    verified_successes: usize,
107}
108
109/// The core agent loop that processes inbound messages.
110///
111/// Consumes messages from the bus, invokes the LLM pipeline, executes
112/// tool calls, and dispatches responses. This struct holds all the
113/// dependencies needed for the full processing cycle.
114///
115/// # Processing flow
116///
117/// 1. **Consume**: Pull the next [`InboundMessage`] from the [`MessageBus`].
118/// 2. **Session**: Look up or create a [`Session`](clawft_types::session::Session)
119///    keyed by `channel:chat_id`.
120/// 3. **Context**: Build the LLM message list via
121///    [`ContextBuilder::build_messages`].
122/// 4. **Pipeline**: Run the assembled context through the 6-stage pipeline
123///    (Classifier -> Router -> Assembler -> Transport -> Scorer -> Learner).
124/// 5. **Tools**: If the LLM response contains tool calls, execute them
125///    via the `ToolRegistry`, append results, and loop back to step 4
126///    (up to `max_tool_iterations`).
127/// 6. **Respond**: Extract the final text response and dispatch an
128///    [`OutboundMessage`] to the bus.
129/// 7. **Persist**: Save the updated session and append to history.
130pub struct AgentLoop<P: Platform> {
131    config: AgentsConfig,
132    platform: Arc<P>,
133    bus: Arc<MessageBus>,
134    pipeline: PipelineRegistry,
135    tools: Arc<ToolRegistry>,
136    context: ContextBuilder<P>,
137    sessions: Arc<SessionManager<P>>,
138    permission_resolver: PermissionResolver,
139    cancel: Option<CancellationToken>,
140    /// Optional pre-LLM auto-delegation router.
141    ///
142    /// When set, inbound messages are checked against delegation rules
143    /// before the local LLM is invoked. If a rule matches, the
144    /// `delegate_task` tool is called directly, bypassing the LLM.
145    auto_delegation: Option<Arc<dyn AutoDelegation>>,
146}
147
148impl<P: Platform> AgentLoop<P> {
149    /// Create a new agent loop with all dependencies wired.
150    ///
151    /// # Arguments
152    ///
153    /// * `config` -- Agent configuration (model, max_tokens, etc.)
154    /// * `platform` -- Platform abstraction for filesystem/env/http
155    /// * `bus` -- Message bus for consuming inbound and dispatching outbound
156    /// * `pipeline` -- Pipeline registry for LLM invocation
157    /// * `tools` -- Tool registry for executing tool calls
158    /// * `context` -- Context builder for assembling prompts
159    /// * `sessions` -- Session manager for conversation persistence
160    #[allow(clippy::too_many_arguments)]
161    pub fn new(
162        config: AgentsConfig,
163        platform: Arc<P>,
164        bus: Arc<MessageBus>,
165        pipeline: PipelineRegistry,
166        tools: Arc<ToolRegistry>,
167        context: ContextBuilder<P>,
168        sessions: Arc<SessionManager<P>>,
169        permission_resolver: PermissionResolver,
170    ) -> Self {
171        Self {
172            config,
173            platform,
174            bus,
175            pipeline,
176            tools,
177            context,
178            sessions,
179            permission_resolver,
180            cancel: None,
181            auto_delegation: None,
182        }
183    }
184
185    /// Attach a [`CancellationToken`] so the agent loop exits promptly on
186    /// shutdown instead of waiting for all bus senders to be dropped.
187    pub fn with_cancel(mut self, token: CancellationToken) -> Self {
188        self.cancel = Some(token);
189        self
190    }
191
192    /// Attach an auto-delegation router for pre-LLM routing.
193    ///
194    /// When set, messages matching delegation rules are routed to the
195    /// `delegate_task` tool before the local LLM processes them.
196    pub fn with_auto_delegation(mut self, delegation: Arc<dyn AutoDelegation>) -> Self {
197        self.auto_delegation = Some(delegation);
198        self
199    }
200
201    /// Get a reference to the agent configuration.
202    pub fn config(&self) -> &AgentsConfig {
203        &self.config
204    }
205
206    /// Get a reference to the platform.
207    pub fn platform(&self) -> &Arc<P> {
208        &self.platform
209    }
210
211    /// Get a reference to the message bus.
212    pub fn bus(&self) -> &Arc<MessageBus> {
213        &self.bus
214    }
215
216    /// Run the agent loop, consuming messages until the bus is closed or
217    /// the optional [`CancellationToken`] is triggered.
218    ///
219    /// This is the main entrypoint. It pulls messages from the inbound
220    /// channel and processes each one through the full pipeline. Errors
221    /// on individual messages are logged but do not terminate the loop.
222    pub async fn run(&self) -> clawft_types::Result<()> {
223        info!("agent loop started, waiting for messages");
224
225        loop {
226            let msg = if let Some(ref token) = self.cancel {
227                #[cfg(feature = "native")]
228                {
229                    tokio::select! {
230                        biased;
231                        _ = token.cancelled() => {
232                            info!("agent loop cancelled via token, exiting");
233                            break;
234                        }
235                        msg = self.bus.consume_inbound() => msg,
236                    }
237                }
238                #[cfg(not(feature = "native"))]
239                {
240                    // On browser, poll cancellation between messages.
241                    if token.is_cancelled() {
242                        info!("agent loop cancelled via token, exiting");
243                        break;
244                    }
245                    self.bus.consume_inbound().await
246                }
247            } else {
248                self.bus.consume_inbound().await
249            };
250
251            match msg {
252                Some(msg) => {
253                    debug!(
254                        channel = %msg.channel,
255                        chat_id = %msg.chat_id,
256                        "processing inbound message"
257                    );
258                    if let Err(e) = self.process_message(msg).await {
259                        error!("failed to process message: {}", e);
260                    }
261                }
262                None => {
263                    info!("inbound channel closed, agent loop exiting");
264                    break;
265                }
266            }
267        }
268
269        Ok(())
270    }
271
272    /// Process a single inbound message through the full pipeline.
273    ///
274    /// Handles session lookup, context building, pipeline invocation,
275    /// the tool execution loop, session persistence, and outbound dispatch.
276    async fn process_message(&self, msg: InboundMessage) -> clawft_types::Result<()> {
277        let session_key = msg.session_key();
278
279        // 0. Pre-LLM auto-delegation check.
280        //    If an AutoDelegation router is configured and the message matches
281        //    a delegation rule, invoke `delegate_task` directly and skip the
282        //    local LLM pipeline entirely.
283        if let Some(ref auto_del) = self.auto_delegation
284            && let Some(delegate_args) = auto_del.should_delegate(&msg.content)
285        {
286            info!(
287                task = %msg.content,
288                "auto-delegation triggered, invoking delegate_task"
289            );
290            return self.run_auto_delegation(&msg, delegate_args).await;
291        }
292
293        // 1. Get or create session
294        let mut session = self.sessions.get_or_create(&session_key).await?;
295
296        // 2. Build context messages from memory, skills, and history BEFORE
297        //    adding the user message to session (to avoid duplicate).
298        let context_messages = self.context.build_messages(&session, &[]).await;
299
300        // 3. Add user message to session (after building context)
301        session.add_message("user", &msg.content, None);
302
303        // 4. Context messages are already pipeline::traits::LlmMessage (B2 unification).
304        let mut messages: Vec<LlmMessage> = context_messages;
305
306        // 4b. Inject skill instructions from metadata (v2 skill activation).
307        //     When the interactive REPL activates a skill, its instructions
308        //     are passed via metadata so the agent loop can include them.
309        if let Some(instructions) = msg
310            .metadata
311            .get("skill_instructions")
312            .and_then(|v| v.as_str())
313            && !instructions.is_empty()
314        {
315            debug!("injecting skill instructions from metadata");
316            messages.push(LlmMessage {
317                role: "system".into(),
318                content: format!("# Active Skill Instructions\n\n{instructions}"),
319                tool_call_id: None,
320                tool_calls: None,
321            });
322        }
323
324        // 4c. Voice mode prompt injection.
325        //     When the message originates from a voice session, inject
326        //     instructions that make the LLM respond in natural spoken
327        //     language instead of written/markdown format. The spoken
328        //     response is sent to TTS; the frontend handles visual display.
329        if msg.chat_id == "voice" {
330            debug!("injecting voice mode system prompt");
331            messages.push(LlmMessage {
332                role: "system".into(),
333                content: VOICE_MODE_PROMPT.into(),
334                tool_call_id: None,
335                tool_calls: None,
336            });
337        }
338
339        // 5. Add current user message
340        messages.push(LlmMessage {
341            role: "user".into(),
342            content: msg.content.clone(),
343            tool_call_id: None,
344            tool_calls: None,
345        });
346
347        // 6. Resolve auth context from inbound message identity.
348        //    CLI channel gets admin permissions; other channels get zero-trust
349        //    defaults with the sender_id and channel attached.
350        let auth_context = self.resolve_auth_context(&msg);
351
352        // 7. Resolve tool schemas -- filter by allowed_tools if present
353        //    in the inbound message metadata (skill-based injection).
354        let tool_schemas = match msg
355            .metadata
356            .get("allowed_tools")
357            .and_then(|v| v.as_array())
358        {
359            Some(tools_arr) => {
360                let allowed: Vec<String> = tools_arr
361                    .iter()
362                    .filter_map(|v| v.as_str().map(String::from))
363                    .collect();
364                if allowed.is_empty() {
365                    self.tools.schemas()
366                } else {
367                    debug!(allowed_tools = ?allowed, "filtering tools for skill");
368                    self.tools.schemas_for_tools(&allowed)
369                }
370            }
371            None => self.tools.schemas(),
372        };
373
374        // 8. Read hallucination score from session metadata and compute boost.
375        let hallucination_score = session
376            .metadata
377            .get(verification::HALLUCINATION_SCORE_KEY)
378            .and_then(|v| v.as_f64())
379            .unwrap_or(0.0) as f32;
380        let complexity_boost = verification::score_to_boost(hallucination_score);
381
382        if complexity_boost > 0.0 {
383            debug!(
384                hallucination_score,
385                complexity_boost,
386                "applying hallucination complexity boost"
387            );
388        }
389
390        // 9. Create pipeline request with auth context + hallucination boost
391        let request = ChatRequest {
392            messages,
393            tools: tool_schemas,
394            model: Some(self.config.defaults.model.clone()),
395            max_tokens: Some(self.config.defaults.max_tokens),
396            temperature: Some(self.config.defaults.temperature),
397            auth_context: Some(auth_context),
398            complexity_boost,
399        };
400
401        // 10. Execute pipeline + tool loop
402        let tool_result = self.run_tool_loop(request).await?;
403
404        // 11. Update hallucination score if any write verifications occurred.
405        if tool_result.hallucinations > 0 || tool_result.verified_successes > 0 {
406            let new_score = verification::update_hallucination_score(
407                hallucination_score,
408                tool_result.hallucinations,
409                tool_result.verified_successes,
410                verification::HALLUCINATION_EMA_ALPHA,
411            );
412            session.metadata.insert(
413                verification::HALLUCINATION_SCORE_KEY.to_string(),
414                serde_json::json!(new_score),
415            );
416            debug!(
417                old_score = hallucination_score,
418                new_score,
419                hallucinations = tool_result.hallucinations,
420                verified = tool_result.verified_successes,
421                "updated hallucination score"
422            );
423        }
424
425        // 12. Add assistant message to session
426        session.add_message("assistant", &tool_result.text, None);
427
428        // 13. Save session
429        self.sessions.save_session(&session).await?;
430
431        // 14. Dispatch outbound
432        let outbound = OutboundMessage {
433            channel: msg.channel.clone(),
434            chat_id: msg.chat_id.clone(),
435            content: tool_result.text,
436            reply_to: None,
437            media: vec![],
438            metadata: Default::default(),
439        };
440        self.bus.dispatch_outbound(outbound)?;
441
442        debug!(session_key = %session_key, "message processed successfully");
443
444        Ok(())
445    }
446
447    /// Execute auto-delegation: invoke `delegate_task` directly and dispatch
448    /// the result as an outbound message.
449    ///
450    /// This short-circuits the normal LLM pipeline when the auto-delegation
451    /// router decides a message should be handled by a delegate (e.g. Claude
452    /// sub-agent) rather than the local LLM.
453    async fn run_auto_delegation(
454        &self,
455        msg: &InboundMessage,
456        delegate_args: serde_json::Value,
457    ) -> clawft_types::Result<()> {
458        let session_key = msg.session_key();
459
460        // Save user message to session for history.
461        let mut session = self.sessions.get_or_create(&session_key).await?;
462        session.add_message("user", &msg.content, None);
463
464        // Resolve auth context for permission checks.
465        let auth = self.resolve_auth_context(msg);
466        let permissions = Some(&auth.permissions);
467
468        // Invoke delegate_task tool directly.
469        let response_text = match self
470            .tools
471            .execute("delegate_task", delegate_args, permissions)
472            .await
473        {
474            Ok(result) => {
475                // Extract the response text from the delegation result.
476                if let Some(response) = result.get("response").and_then(|v| v.as_str()) {
477                    response.to_string()
478                } else {
479                    // Fall back to the full JSON if no "response" field.
480                    serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
481                }
482            }
483            Err(e) => {
484                warn!(error = %e, "auto-delegation failed, falling through to local LLM");
485                // On delegation failure, fall through to normal processing.
486                // Re-process via the full pipeline by calling process_message_inner.
487                // For simplicity, return an error message to the user.
488                format!("Delegation failed: {e}. The task could not be routed to the delegate.")
489            }
490        };
491
492        // Save response to session.
493        session.add_message("assistant", &response_text, None);
494        self.sessions.save_session(&session).await?;
495
496        // Dispatch outbound.
497        let outbound = OutboundMessage {
498            channel: msg.channel.clone(),
499            chat_id: msg.chat_id.clone(),
500            content: response_text,
501            reply_to: None,
502            media: vec![],
503            metadata: Default::default(),
504        };
505        self.bus.dispatch_outbound(outbound)?;
506
507        debug!(session_key = %session_key, "auto-delegated message processed");
508        Ok(())
509    }
510
511    /// Resolve [`AuthContext`] from the inbound message's sender identity.
512    ///
513    /// Resolve permissions for an inbound message using the 5-layer
514    /// [`PermissionResolver`].
515    ///
516    /// Resolution order (highest priority wins):
517    /// 1. Built-in level defaults
518    /// 2. Global config level overrides
519    /// 3. Workspace config level overrides
520    /// 4. Per-user overrides
521    /// 5. Per-channel overrides (highest priority)
522    ///
523    /// CLI channel messages always receive admin-level (Level 2)
524    /// permissions via the resolver's `cli_default_level`.
525    fn resolve_auth_context(&self, msg: &InboundMessage) -> AuthContext {
526        // Channel plugins set "allow_from_match" in metadata when the sender
527        // passed the channel's allow_from verification. This promotes the
528        // sender from zero-trust to at least user-level permissions.
529        let allow_from_match = msg
530            .metadata
531            .get("allow_from_match")
532            .and_then(|v| v.as_bool())
533            .unwrap_or(false);
534        self.permission_resolver
535            .resolve_auth_context(&msg.sender_id, &msg.channel, allow_from_match)
536    }
537
538    /// Resolve the workspace path from config, expanding `~` to home dir.
539    fn workspace_path(&self) -> std::path::PathBuf {
540        let raw = &self.config.defaults.workspace;
541        if let Some(rest) = raw.strip_prefix("~/")
542            && let Some(home) = self.platform.fs().home_dir()
543        {
544            return home.join(rest);
545        }
546        std::path::PathBuf::from(raw)
547    }
548
549    /// Execute the tool loop: call LLM, execute tools, repeat.
550    ///
551    /// After each LLM call, checks if the response contains tool-use
552    /// requests. If so, executes each tool via the `ToolRegistry`, appends
553    /// tool results to the message list, and re-invokes the pipeline.
554    /// Continues until the LLM returns a text response or the maximum
555    /// iteration limit is reached.
556    ///
557    /// Post-write verification checks whether files claimed by write/edit
558    /// tools actually exist on disk. Hallucinated results are replaced with
559    /// error messages so the LLM can retry.
560    async fn run_tool_loop(
561        &self,
562        mut request: ChatRequest,
563    ) -> clawft_types::Result<ToolLoopResult> {
564        let max_iterations = self.config.defaults.max_tool_iterations.max(1) as usize;
565        let mut total_hallucinations: usize = 0;
566        let mut total_verified: usize = 0;
567        let workspace = self.workspace_path();
568
569        for iteration in 0..max_iterations {
570            let response = self.pipeline.complete(&request).await?;
571
572            // Extract tool calls from the response
573            let tool_calls: Vec<(String, String, serde_json::Value)> = response
574                .content
575                .iter()
576                .filter_map(|block| {
577                    if let ContentBlock::ToolUse { id, name, input } = block {
578                        Some((id.clone(), name.clone(), input.clone()))
579                    } else {
580                        None
581                    }
582                })
583                .collect();
584
585            if tool_calls.is_empty() {
586                // No tool calls -- extract text response and return
587                let text = response
588                    .content
589                    .iter()
590                    .filter_map(|block| {
591                        if let ContentBlock::Text { text } = block {
592                            Some(text.as_str())
593                        } else {
594                            None
595                        }
596                    })
597                    .collect::<Vec<_>>()
598                    .join("");
599
600                debug!(iteration, "tool loop complete, returning text response");
601                return Ok(ToolLoopResult {
602                    text,
603                    hallucinations: total_hallucinations,
604                    verified_successes: total_verified,
605                });
606            }
607
608            debug!(
609                iteration,
610                tool_count = tool_calls.len(),
611                "executing tool calls"
612            );
613
614            // Append the assistant message (with tool_calls) to the conversation
615            // so the next LLM request sees the correct message sequence:
616            //   ... user -> assistant (tool_use) -> tool results -> ...
617            let assistant_tool_calls: Vec<serde_json::Value> = tool_calls
618                .iter()
619                .map(|(id, name, input)| {
620                    serde_json::json!({
621                        "id": id,
622                        "type": "function",
623                        "function": {
624                            "name": name,
625                            "arguments": serde_json::to_string(input).unwrap_or_default(),
626                        }
627                    })
628                })
629                .collect();
630
631            // Extract any text content from the response for the assistant message
632            let assistant_text: String = response
633                .content
634                .iter()
635                .filter_map(|block| {
636                    if let ContentBlock::Text { text } = block {
637                        Some(text.as_str())
638                    } else {
639                        None
640                    }
641                })
642                .collect::<Vec<_>>()
643                .join("");
644
645            request.messages.push(LlmMessage {
646                role: "assistant".into(),
647                content: assistant_text,
648                tool_call_id: None,
649                tool_calls: Some(assistant_tool_calls),
650            });
651
652            // Execute all tool calls in parallel and append results in order.
653            let permissions = request
654                .auth_context
655                .as_ref()
656                .map(|ctx| &ctx.permissions);
657
658            let futures: Vec<_> = tool_calls
659                .iter()
660                .map(|(id, name, input)| {
661                    let tools = &self.tools;
662                    async move {
663                        let result = tools.execute(name, input.clone(), permissions).await;
664                        let result_json = match result {
665                            Ok(val) => {
666                                let truncated =
667                                    crate::security::truncate_result(val, MAX_TOOL_RESULT_BYTES);
668                                serde_json::to_string(&truncated).unwrap_or_default()
669                            }
670                            Err(e) => {
671                                error!(tool = %name, error = %e, "tool execution failed");
672                                serde_json::json!({"error": e.to_string()}).to_string()
673                            }
674                        };
675                        (id.clone(), name.clone(), result_json)
676                    }
677                })
678                .collect();
679
680            let results = futures_util::future::join_all(futures).await;
681
682            // Post-write verification: check that claimed writes exist on disk.
683            let verification_results = verification::verify_write_results(
684                self.platform.fs(),
685                &workspace,
686                &results,
687            )
688            .await;
689
690            // Build a set of hallucinated tool call IDs for result replacement.
691            let hallucinated_ids: std::collections::HashSet<String> = verification_results
692                .iter()
693                .filter(|v| !v.verified)
694                .map(|v| v.tool_call_id.clone())
695                .collect();
696
697            // Count verification outcomes.
698            for vr in &verification_results {
699                if vr.verified {
700                    total_verified += 1;
701                } else {
702                    total_hallucinations += 1;
703                    warn!(
704                        tool_call_id = %vr.tool_call_id,
705                        path = %vr.claimed_path.display(),
706                        "VERIFICATION FAILED: file does not exist (hallucinated write)"
707                    );
708                }
709            }
710
711            for (id, _name, result_json) in &results {
712                let content = if hallucinated_ids.contains(id) {
713                    // Replace the success result with a verification failure error.
714                    serde_json::json!({
715                        "error": "VERIFICATION FAILED: the file you claimed to write does not exist on disk. The write was hallucinated. Please retry the write operation."
716                    }).to_string()
717                } else {
718                    result_json.clone()
719                };
720
721                request.messages.push(LlmMessage {
722                    role: "tool".into(),
723                    content,
724                    tool_call_id: Some(id.clone()),
725                    tool_calls: None,
726                });
727            }
728        }
729
730        Err(ClawftError::Provider {
731            message: format!("max tool iterations ({}) exceeded", max_iterations),
732        })
733    }
734
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crate::agent::memory::MemoryStore;
741    use crate::agent::skills::SkillsLoader;
742    use crate::pipeline::traits::{
743        AssembledContext, LearningBackend, LearningSignal, LlmTransport, ModelRouter, Pipeline,
744        QualityScore, QualityScorer, ResponseOutcome, RoutingDecision, TaskClassifier, TaskProfile,
745        TaskType, Trajectory, TransportRequest,
746    };
747    use crate::tools::registry::Tool;
748    use async_trait::async_trait;
749    use clawft_platform::NativePlatform;
750    use clawft_types::config::AgentDefaults;
751    use clawft_types::provider::{LlmResponse, StopReason, Usage};
752    use std::collections::HashMap;
753    use std::path::PathBuf;
754    use std::sync::atomic::{AtomicU64, Ordering};
755
756    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
757
758    fn temp_dir(prefix: &str) -> PathBuf {
759        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
760        let pid = std::process::id();
761        std::env::temp_dir().join(format!("clawft_loop_test_{prefix}_{pid}_{id}"))
762    }
763
764    fn test_config() -> AgentsConfig {
765        AgentsConfig {
766            defaults: AgentDefaults {
767                workspace: "~/.clawft/workspace".into(),
768                model: "test-model".into(),
769                max_tokens: 4096,
770                temperature: 0.5,
771                max_tool_iterations: 10,
772                memory_window: 50,
773            },
774        }
775    }
776
777    // -- Mock pipeline stages --
778
779    struct MockClassifier;
780    impl TaskClassifier for MockClassifier {
781        fn classify(&self, _request: &ChatRequest) -> TaskProfile {
782            TaskProfile {
783                task_type: TaskType::Chat,
784                complexity: 0.3,
785                keywords: vec![],
786            }
787        }
788    }
789
790    struct MockRouter;
791    #[async_trait]
792    impl ModelRouter for MockRouter {
793        async fn route(&self, _request: &ChatRequest, _profile: &TaskProfile) -> RoutingDecision {
794            RoutingDecision {
795                provider: "test".into(),
796                model: "test-model".into(),
797                reason: "mock".into(),
798                ..Default::default()
799            }
800        }
801        fn update(&self, _d: &RoutingDecision, _o: &ResponseOutcome) {}
802    }
803
804    struct MockAssembler;
805    #[async_trait]
806    impl crate::pipeline::traits::ContextAssembler for MockAssembler {
807        async fn assemble(
808            &self,
809            request: &ChatRequest,
810            _profile: &TaskProfile,
811        ) -> AssembledContext {
812            AssembledContext {
813                messages: request.messages.clone(),
814                token_estimate: 100,
815                truncated: false,
816            }
817        }
818    }
819
820    /// Transport that returns a fixed text response.
821    struct MockTransport {
822        response_text: String,
823    }
824
825    impl MockTransport {
826        fn new(text: &str) -> Self {
827            Self {
828                response_text: text.into(),
829            }
830        }
831    }
832
833    #[async_trait]
834    impl LlmTransport for MockTransport {
835        async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
836            Ok(LlmResponse {
837                id: "mock-resp".into(),
838                content: vec![ContentBlock::Text {
839                    text: self.response_text.clone(),
840                }],
841                stop_reason: StopReason::EndTurn,
842                usage: Usage {
843                    input_tokens: 10,
844                    output_tokens: 5,
845                    total_tokens: 0,
846                },
847                metadata: HashMap::new(),
848            })
849        }
850    }
851
852    /// Transport that returns a tool call first, then text.
853    struct MockToolTransport {
854        call_count: std::sync::atomic::AtomicUsize,
855    }
856
857    impl MockToolTransport {
858        fn new() -> Self {
859            Self {
860                call_count: std::sync::atomic::AtomicUsize::new(0),
861            }
862        }
863    }
864
865    #[async_trait]
866    impl LlmTransport for MockToolTransport {
867        async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
868            let count = self
869                .call_count
870                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
871            if count == 0 {
872                // First call: request a tool use
873                Ok(LlmResponse {
874                    id: "mock-tool-resp".into(),
875                    content: vec![ContentBlock::ToolUse {
876                        id: "call-1".into(),
877                        name: "echo".into(),
878                        input: serde_json::json!({"text": "hello"}),
879                    }],
880                    stop_reason: StopReason::ToolUse,
881                    usage: Usage {
882                        input_tokens: 10,
883                        output_tokens: 5,
884                        total_tokens: 0,
885                    },
886                    metadata: HashMap::new(),
887                })
888            } else {
889                // Second call: return text
890                Ok(LlmResponse {
891                    id: "mock-final-resp".into(),
892                    content: vec![ContentBlock::Text {
893                        text: "tool result processed".into(),
894                    }],
895                    stop_reason: StopReason::EndTurn,
896                    usage: Usage {
897                        input_tokens: 20,
898                        output_tokens: 8,
899                        total_tokens: 0,
900                    },
901                    metadata: HashMap::new(),
902                })
903            }
904        }
905    }
906
907    /// Transport that always returns tool calls (to test max iterations).
908    struct InfiniteToolTransport;
909
910    #[async_trait]
911    impl LlmTransport for InfiniteToolTransport {
912        async fn complete(&self, _request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
913            Ok(LlmResponse {
914                id: "infinite".into(),
915                content: vec![ContentBlock::ToolUse {
916                    id: "call-inf".into(),
917                    name: "echo".into(),
918                    input: serde_json::json!({"text": "loop"}),
919                }],
920                stop_reason: StopReason::ToolUse,
921                usage: Usage {
922                    input_tokens: 5,
923                    output_tokens: 3,
924                    total_tokens: 0,
925                },
926                metadata: HashMap::new(),
927            })
928        }
929    }
930
931    struct MockScorer;
932    impl QualityScorer for MockScorer {
933        fn score(&self, _req: &ChatRequest, _resp: &LlmResponse) -> QualityScore {
934            QualityScore {
935                overall: 1.0,
936                relevance: 1.0,
937                coherence: 1.0,
938            }
939        }
940    }
941
942    struct MockLearner;
943    impl LearningBackend for MockLearner {
944        fn record(&self, _t: &Trajectory) {}
945        fn adapt(&self, _s: &LearningSignal) {}
946    }
947
948    fn make_pipeline(transport: Arc<dyn LlmTransport>) -> PipelineRegistry {
949        let pipeline = Pipeline {
950            classifier: Arc::new(MockClassifier),
951            router: Arc::new(MockRouter),
952            assembler: Arc::new(MockAssembler),
953            transport,
954            scorer: Arc::new(MockScorer),
955            learner: Arc::new(MockLearner),
956        };
957        PipelineRegistry::new(pipeline)
958    }
959
960    // -- Mock tool --
961
962    struct EchoTool;
963
964    #[async_trait]
965    impl Tool for EchoTool {
966        fn name(&self) -> &str {
967            "echo"
968        }
969        fn description(&self) -> &str {
970            "Echo back the input text"
971        }
972        fn parameters(&self) -> serde_json::Value {
973            serde_json::json!({
974                "type": "object",
975                "properties": {
976                    "text": { "type": "string" }
977                },
978                "required": ["text"]
979            })
980        }
981        async fn execute(
982            &self,
983            args: serde_json::Value,
984        ) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
985            let text = args
986                .get("text")
987                .and_then(|v| v.as_str())
988                .unwrap_or("(no text)");
989            Ok(serde_json::json!({"output": text}))
990        }
991    }
992
993    /// Helper to create an AgentLoop with the given transport.
994    async fn make_agent_loop(
995        transport: Arc<dyn LlmTransport>,
996        prefix: &str,
997    ) -> (AgentLoop<NativePlatform>, PathBuf) {
998        let dir = temp_dir(prefix);
999        let platform = Arc::new(NativePlatform::new());
1000        let bus = Arc::new(MessageBus::new());
1001
1002        let sessions_dir = dir.join("sessions");
1003        let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
1004
1005        let memory = Arc::new(MemoryStore::with_paths(
1006            dir.join("memory").join("MEMORY.md"),
1007            dir.join("memory").join("HISTORY.md"),
1008            platform.clone(),
1009        ));
1010        let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
1011        let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
1012
1013        let mut tools = ToolRegistry::new();
1014        tools.register(Arc::new(EchoTool));
1015
1016        let pipeline = make_pipeline(transport);
1017
1018        let agent = AgentLoop::new(
1019            test_config(),
1020            platform,
1021            bus,
1022            pipeline,
1023            Arc::new(tools),
1024            context,
1025            Arc::new(sessions),
1026            PermissionResolver::default_resolver(),
1027        );
1028        (agent, dir)
1029    }
1030
1031    #[test]
1032    fn new_creates_agent_loop_with_all_deps() {
1033        let rt = tokio::runtime::Runtime::new().unwrap();
1034        rt.block_on(async {
1035            let transport = Arc::new(MockTransport::new("hello"));
1036            let (agent, dir) = make_agent_loop(transport, "new_all").await;
1037
1038            assert_eq!(agent.config().defaults.model, "test-model");
1039            assert_eq!(agent.config().defaults.max_tokens, 4096);
1040            assert_eq!(agent.config().defaults.max_tool_iterations, 10);
1041
1042            let _ = tokio::fs::remove_dir_all(&dir).await;
1043        });
1044    }
1045
1046    #[test]
1047    fn config_accessor_returns_config() {
1048        let rt = tokio::runtime::Runtime::new().unwrap();
1049        rt.block_on(async {
1050            let transport = Arc::new(MockTransport::new("hello"));
1051            let (agent, dir) = make_agent_loop(transport, "config_acc").await;
1052
1053            assert_eq!(agent.config().defaults.memory_window, 50);
1054            assert_eq!(agent.config().defaults.workspace, "~/.clawft/workspace");
1055
1056            let _ = tokio::fs::remove_dir_all(&dir).await;
1057        });
1058    }
1059
1060    #[test]
1061    fn platform_accessor_returns_platform() {
1062        let rt = tokio::runtime::Runtime::new().unwrap();
1063        rt.block_on(async {
1064            let transport = Arc::new(MockTransport::new("hello"));
1065            let (agent, dir) = make_agent_loop(transport, "platform_acc").await;
1066
1067            // Verify the platform reference is accessible
1068            let _p = agent.platform();
1069
1070            let _ = tokio::fs::remove_dir_all(&dir).await;
1071        });
1072    }
1073
1074    #[tokio::test]
1075    async fn process_message_produces_outbound() {
1076        let transport = Arc::new(MockTransport::new("Hello from LLM!"));
1077        let (agent, dir) = make_agent_loop(transport, "process_msg").await;
1078
1079        // Publish an inbound message
1080        let inbound = InboundMessage {
1081            channel: "test".into(),
1082            sender_id: "user1".into(),
1083            chat_id: "chat1".into(),
1084            content: "hi there".into(),
1085            timestamp: chrono::Utc::now(),
1086            media: vec![],
1087            metadata: HashMap::new(),
1088        };
1089        agent.bus.publish_inbound(inbound).unwrap();
1090
1091        // Process it
1092        let msg = agent.bus.consume_inbound().await.unwrap();
1093        agent.process_message(msg).await.unwrap();
1094
1095        // Check outbound
1096        let outbound = agent.bus.consume_outbound().await.unwrap();
1097        assert_eq!(outbound.channel, "test");
1098        assert_eq!(outbound.chat_id, "chat1");
1099        assert_eq!(outbound.content, "Hello from LLM!");
1100
1101        let _ = tokio::fs::remove_dir_all(&dir).await;
1102    }
1103
1104    #[tokio::test]
1105    async fn process_message_executes_tool_loop() {
1106        let transport = Arc::new(MockToolTransport::new());
1107        let (agent, dir) = make_agent_loop(transport, "tool_loop").await;
1108
1109        let inbound = InboundMessage {
1110            channel: "test".into(),
1111            sender_id: "user1".into(),
1112            chat_id: "chat1".into(),
1113            content: "use echo tool".into(),
1114            timestamp: chrono::Utc::now(),
1115            media: vec![],
1116            metadata: HashMap::new(),
1117        };
1118        agent.bus.publish_inbound(inbound).unwrap();
1119
1120        let msg = agent.bus.consume_inbound().await.unwrap();
1121        agent.process_message(msg).await.unwrap();
1122
1123        let outbound = agent.bus.consume_outbound().await.unwrap();
1124        assert_eq!(outbound.content, "tool result processed");
1125
1126        let _ = tokio::fs::remove_dir_all(&dir).await;
1127    }
1128
1129    #[tokio::test]
1130    async fn run_tool_loop_respects_max_iterations() {
1131        let transport = Arc::new(InfiniteToolTransport);
1132        let (agent, dir) = make_agent_loop(transport, "max_iter").await;
1133
1134        let request = ChatRequest {
1135            messages: vec![LlmMessage {
1136                role: "user".into(),
1137                content: "loop forever".into(),
1138                tool_call_id: None,
1139                tool_calls: None,
1140            }],
1141            tools: vec![],
1142            model: Some("test-model".into()),
1143            max_tokens: Some(4096),
1144            temperature: Some(0.5),
1145            auth_context: None,
1146            complexity_boost: 0.0,
1147        };
1148
1149        let result = agent.run_tool_loop(request).await;
1150        assert!(result.is_err());
1151        let err_msg = result.unwrap_err().to_string();
1152        assert!(
1153            err_msg.contains("max tool iterations"),
1154            "error should mention max iterations: {}",
1155            err_msg
1156        );
1157
1158        let _ = tokio::fs::remove_dir_all(&dir).await;
1159    }
1160
1161    #[tokio::test]
1162    async fn run_exits_when_bus_closes() {
1163        let transport = Arc::new(MockTransport::new("hello"));
1164        let (agent, dir) = make_agent_loop(transport, "bus_close").await;
1165
1166        // Drop the inbound sender by dropping a cloned bus reference.
1167        // We need to drop all inbound senders. The bus holds one internally.
1168        // Simplest: publish a message, consume it, then drop the bus.
1169        // Since the agent holds an Arc<MessageBus>, we cannot fully drop it.
1170        // Instead, test that run() exits when the channel is closed by
1171        // spawning run in a background task and sending a message that
1172        // processes, then dropping the bus's sender.
1173
1174        // We cannot easily test the full `run()` loop exit here since the
1175        // bus is shared via Arc. Instead test the contract: consume_inbound
1176        // returns None when all senders are dropped.
1177        // This is already tested in bus.rs. Here we verify the struct compiles.
1178        assert!(
1179            agent
1180                .bus()
1181                .inbound_sender()
1182                .send(InboundMessage {
1183                    channel: "test".into(),
1184                    sender_id: "u".into(),
1185                    chat_id: "c".into(),
1186                    content: "msg".into(),
1187                    timestamp: chrono::Utc::now(),
1188                    media: vec![],
1189                    metadata: HashMap::new(),
1190                })
1191                .await
1192                .is_ok()
1193        );
1194
1195        let _ = tokio::fs::remove_dir_all(&dir).await;
1196    }
1197
1198    #[tokio::test]
1199    async fn process_message_saves_session() {
1200        let transport = Arc::new(MockTransport::new("saved response"));
1201        let (agent, dir) = make_agent_loop(transport, "session_save").await;
1202
1203        let inbound = InboundMessage {
1204            channel: "telegram".into(),
1205            sender_id: "user1".into(),
1206            chat_id: "chat42".into(),
1207            content: "remember this".into(),
1208            timestamp: chrono::Utc::now(),
1209            media: vec![],
1210            metadata: HashMap::new(),
1211        };
1212        agent.bus.publish_inbound(inbound).unwrap();
1213
1214        let msg = agent.bus.consume_inbound().await.unwrap();
1215        agent.process_message(msg).await.unwrap();
1216
1217        // Verify session was saved with both messages
1218        let session = agent
1219            .sessions
1220            .get_or_create("telegram:chat42")
1221            .await
1222            .unwrap();
1223        // Session should have user message + assistant message
1224        assert!(session.messages.len() >= 2);
1225
1226        let _ = tokio::fs::remove_dir_all(&dir).await;
1227    }
1228
1229    #[test]
1230    fn agent_loop_is_send() {
1231        fn assert_send<T: Send>() {}
1232        assert_send::<AgentLoop<NativePlatform>>();
1233    }
1234
1235    // ── GAP-19: Tool result truncation verification ───────────────────
1236
1237    /// Transport that returns a tool call for a tool producing oversized output.
1238    struct OversizedToolTransport {
1239        call_count: std::sync::atomic::AtomicUsize,
1240    }
1241
1242    impl OversizedToolTransport {
1243        fn new() -> Self {
1244            Self {
1245                call_count: std::sync::atomic::AtomicUsize::new(0),
1246            }
1247        }
1248    }
1249
1250    #[async_trait]
1251    impl LlmTransport for OversizedToolTransport {
1252        async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1253            let count = self
1254                .call_count
1255                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1256            if count == 0 {
1257                // First call: request a tool call that will produce oversized output
1258                Ok(LlmResponse {
1259                    id: "oversized-resp".into(),
1260                    content: vec![ContentBlock::ToolUse {
1261                        id: "call-big".into(),
1262                        name: "big_output".into(),
1263                        input: serde_json::json!({}),
1264                    }],
1265                    stop_reason: StopReason::ToolUse,
1266                    usage: Usage {
1267                        input_tokens: 10,
1268                        output_tokens: 5,
1269                        total_tokens: 0,
1270                    },
1271                    metadata: HashMap::new(),
1272                })
1273            } else {
1274                // Second call: verify the tool result was included (truncated)
1275                // and return text. Check message list for truncation.
1276                let last_msg = request.messages.last();
1277                let content_text = last_msg
1278                    .map(|m| m.content.as_str())
1279                    .unwrap_or("no tool result");
1280
1281                Ok(LlmResponse {
1282                    id: "final-resp".into(),
1283                    content: vec![ContentBlock::Text {
1284                        text: format!("tool_result_len:{}", content_text.len()),
1285                    }],
1286                    stop_reason: StopReason::EndTurn,
1287                    usage: Usage {
1288                        input_tokens: 20,
1289                        output_tokens: 8,
1290                        total_tokens: 0,
1291                    },
1292                    metadata: HashMap::new(),
1293                })
1294            }
1295        }
1296    }
1297
1298    /// Tool that produces output exceeding MAX_TOOL_RESULT_BYTES.
1299    struct BigOutputTool;
1300
1301    #[async_trait]
1302    impl Tool for BigOutputTool {
1303        fn name(&self) -> &str {
1304            "big_output"
1305        }
1306        fn description(&self) -> &str {
1307            "Returns a very large output"
1308        }
1309        fn parameters(&self) -> serde_json::Value {
1310            serde_json::json!({
1311                "type": "object",
1312                "properties": {}
1313            })
1314        }
1315        async fn execute(
1316            &self,
1317            _args: serde_json::Value,
1318        ) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
1319            // Produce output far exceeding 64KB (MAX_TOOL_RESULT_BYTES)
1320            let big_string = "x".repeat(200_000);
1321            Ok(serde_json::json!({"data": big_string}))
1322        }
1323    }
1324
1325    #[tokio::test]
1326    async fn tool_result_truncation_enforced() {
1327        let dir = temp_dir("truncation");
1328        let platform = Arc::new(NativePlatform::new());
1329        let bus = Arc::new(MessageBus::new());
1330
1331        let sessions_dir = dir.join("sessions");
1332        let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
1333
1334        let memory = Arc::new(MemoryStore::with_paths(
1335            dir.join("memory").join("MEMORY.md"),
1336            dir.join("memory").join("HISTORY.md"),
1337            platform.clone(),
1338        ));
1339        let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
1340        let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
1341
1342        let mut tools = ToolRegistry::new();
1343        tools.register(Arc::new(BigOutputTool));
1344
1345        let pipeline = make_pipeline(Arc::new(OversizedToolTransport::new()));
1346
1347        let agent = AgentLoop::new(
1348            test_config(),
1349            platform,
1350            bus,
1351            pipeline,
1352            Arc::new(tools),
1353            context,
1354            Arc::new(sessions),
1355            PermissionResolver::default_resolver(),
1356        );
1357
1358        let request = ChatRequest {
1359            messages: vec![LlmMessage {
1360                role: "user".into(),
1361                content: "trigger big tool".into(),
1362                tool_call_id: None,
1363                tool_calls: None,
1364            }],
1365            tools: vec![],
1366            model: Some("test-model".into()),
1367            max_tokens: Some(4096),
1368            temperature: Some(0.5),
1369            auth_context: None,
1370            complexity_boost: 0.0,
1371        };
1372
1373        let tool_result = agent.run_tool_loop(request).await.unwrap();
1374        let result = &tool_result.text;
1375
1376        // The tool result should have been truncated to MAX_TOOL_RESULT_BYTES (65536).
1377        // The response tells us the length of the tool result message.
1378        assert!(
1379            result.starts_with("tool_result_len:"),
1380            "response should contain truncated tool result length: {result}"
1381        );
1382        let len_str = result.strip_prefix("tool_result_len:").unwrap();
1383        let result_len: usize = len_str.parse().unwrap();
1384        assert!(
1385            result_len <= MAX_TOOL_RESULT_BYTES,
1386            "tool result ({result_len} bytes) should be truncated to <= {} bytes",
1387            MAX_TOOL_RESULT_BYTES
1388        );
1389
1390        let _ = tokio::fs::remove_dir_all(&dir).await;
1391    }
1392
1393    // ── TEST-04: Agent loop end-to-end test ────────────────────────────
1394
1395    /// Transport that records every request it receives and drives a full
1396    /// tool-use round-trip: call 1 returns tool_use, call 2 verifies the
1397    /// tool result was appended and returns text.
1398    struct E2eRecordingTransport {
1399        call_count: std::sync::atomic::AtomicUsize,
1400        /// Snapshot of message lists received on each call.
1401        recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
1402    }
1403
1404    impl E2eRecordingTransport {
1405        fn new() -> Self {
1406            Self {
1407                call_count: std::sync::atomic::AtomicUsize::new(0),
1408                recorded_requests: std::sync::Mutex::new(Vec::new()),
1409            }
1410        }
1411
1412        fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
1413            self.recorded_requests.lock().unwrap().clone()
1414        }
1415    }
1416
1417    #[async_trait]
1418    impl LlmTransport for E2eRecordingTransport {
1419        async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1420            // Record the incoming message list
1421            self.recorded_requests
1422                .lock()
1423                .unwrap()
1424                .push(request.messages.clone());
1425
1426            let count = self
1427                .call_count
1428                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1429
1430            if count == 0 {
1431                // Call 1: LLM returns a tool_use request
1432                Ok(LlmResponse {
1433                    id: "e2e-resp-1".into(),
1434                    content: vec![ContentBlock::ToolUse {
1435                        id: "call-e2e-1".into(),
1436                        name: "echo".into(),
1437                        input: serde_json::json!({"text": "e2e-ping"}),
1438                    }],
1439                    stop_reason: StopReason::ToolUse,
1440                    usage: Usage {
1441                        input_tokens: 15,
1442                        output_tokens: 10,
1443                        total_tokens: 0,
1444                    },
1445                    metadata: HashMap::new(),
1446                })
1447            } else {
1448                // Call 2: LLM receives tool result, returns final text
1449                Ok(LlmResponse {
1450                    id: "e2e-resp-2".into(),
1451                    content: vec![ContentBlock::Text {
1452                        text: "I received the tool output successfully".into(),
1453                    }],
1454                    stop_reason: StopReason::EndTurn,
1455                    usage: Usage {
1456                        input_tokens: 25,
1457                        output_tokens: 12,
1458                        total_tokens: 0,
1459                    },
1460                    metadata: HashMap::new(),
1461                })
1462            }
1463        }
1464    }
1465
1466    /// Multi-tool transport: returns two tool calls on the first invocation,
1467    /// then text on the second.
1468    struct MultiToolE2eTransport {
1469        call_count: std::sync::atomic::AtomicUsize,
1470        recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
1471    }
1472
1473    impl MultiToolE2eTransport {
1474        fn new() -> Self {
1475            Self {
1476                call_count: std::sync::atomic::AtomicUsize::new(0),
1477                recorded_requests: std::sync::Mutex::new(Vec::new()),
1478            }
1479        }
1480
1481        fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
1482            self.recorded_requests.lock().unwrap().clone()
1483        }
1484    }
1485
1486    #[async_trait]
1487    impl LlmTransport for MultiToolE2eTransport {
1488        async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1489            self.recorded_requests
1490                .lock()
1491                .unwrap()
1492                .push(request.messages.clone());
1493
1494            let count = self
1495                .call_count
1496                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1497
1498            if count == 0 {
1499                // Return two tool calls at once
1500                Ok(LlmResponse {
1501                    id: "multi-tool-resp-1".into(),
1502                    content: vec![
1503                        ContentBlock::ToolUse {
1504                            id: "call-mt-1".into(),
1505                            name: "echo".into(),
1506                            input: serde_json::json!({"text": "first"}),
1507                        },
1508                        ContentBlock::ToolUse {
1509                            id: "call-mt-2".into(),
1510                            name: "echo".into(),
1511                            input: serde_json::json!({"text": "second"}),
1512                        },
1513                    ],
1514                    stop_reason: StopReason::ToolUse,
1515                    usage: Usage {
1516                        input_tokens: 20,
1517                        output_tokens: 15,
1518                        total_tokens: 0,
1519                    },
1520                    metadata: HashMap::new(),
1521                })
1522            } else {
1523                Ok(LlmResponse {
1524                    id: "multi-tool-resp-2".into(),
1525                    content: vec![ContentBlock::Text {
1526                        text: "processed both tools".into(),
1527                    }],
1528                    stop_reason: StopReason::EndTurn,
1529                    usage: Usage {
1530                        input_tokens: 30,
1531                        output_tokens: 10,
1532                        total_tokens: 0,
1533                    },
1534                    metadata: HashMap::new(),
1535                })
1536            }
1537        }
1538    }
1539
1540    /// TEST-04: Full e2e test -- mock LLM returns tool_use, tool executes,
1541    /// result is sent back to LLM, LLM returns text. Verifies the full
1542    /// message chain including intermediate tool result messages.
1543    #[tokio::test]
1544    async fn e2e_tool_roundtrip_message_chain() {
1545        let transport = Arc::new(E2eRecordingTransport::new());
1546        let transport_ref = transport.clone();
1547        let (agent, dir) = make_agent_loop(transport, "e2e_chain").await;
1548
1549        // Publish and process a message that triggers tool use.
1550        // Use channel "cli" so resolve_auth_context grants admin permissions,
1551        // allowing the echo tool to execute through the permission check.
1552        let inbound = InboundMessage {
1553            channel: "cli".into(),
1554            sender_id: "e2e-user".into(),
1555            chat_id: "e2e-chat".into(),
1556            content: "please use the echo tool".into(),
1557            timestamp: chrono::Utc::now(),
1558            media: vec![],
1559            metadata: HashMap::new(),
1560        };
1561        agent.bus.publish_inbound(inbound).unwrap();
1562        let msg = agent.bus.consume_inbound().await.unwrap();
1563        agent.process_message(msg).await.unwrap();
1564
1565        // Verify outbound message is the final text response
1566        let outbound = agent.bus.consume_outbound().await.unwrap();
1567        assert_eq!(outbound.content, "I received the tool output successfully");
1568        assert_eq!(outbound.channel, "cli");
1569        assert_eq!(outbound.chat_id, "e2e-chat");
1570
1571        // Verify the transport was called exactly twice
1572        let snapshots = transport_ref.snapshots();
1573        assert_eq!(
1574            snapshots.len(),
1575            2,
1576            "transport should be called twice (tool_use -> text)"
1577        );
1578
1579        // Snapshot 1: initial user request
1580        let first_call = &snapshots[0];
1581        assert!(
1582            first_call.iter().any(|m| m.role == "user"),
1583            "first call should contain user message"
1584        );
1585
1586        // Snapshot 2: should include the tool result message
1587        let second_call = &snapshots[1];
1588        let tool_result_msg = second_call
1589            .iter()
1590            .find(|m| m.role == "tool")
1591            .expect("second call should contain a tool result message");
1592
1593        // Verify the tool result has the correct tool_call_id
1594        assert_eq!(
1595            tool_result_msg.tool_call_id.as_deref(),
1596            Some("call-e2e-1"),
1597            "tool result should reference the tool call ID"
1598        );
1599
1600        // Verify the tool result contains the echo output
1601        assert!(
1602            tool_result_msg.content.contains("e2e-ping"),
1603            "tool result should contain the echoed text: {}",
1604            tool_result_msg.content
1605        );
1606
1607        // Verify session was persisted with both user and assistant messages
1608        let session = agent
1609            .sessions
1610            .get_or_create("cli:e2e-chat")
1611            .await
1612            .unwrap();
1613        assert!(
1614            session.messages.len() >= 2,
1615            "session should have at least user + assistant messages, got {}",
1616            session.messages.len()
1617        );
1618
1619        let _ = tokio::fs::remove_dir_all(&dir).await;
1620    }
1621
1622    /// TEST-04: E2e test with multiple tool calls in a single LLM response.
1623    /// Verifies that all tool results are sent back with correct IDs.
1624    #[tokio::test]
1625    async fn e2e_multi_tool_calls_all_results_returned() {
1626        let transport = Arc::new(MultiToolE2eTransport::new());
1627        let transport_ref = transport.clone();
1628        let (agent, dir) = make_agent_loop(transport, "e2e_multi").await;
1629
1630        // Use channel "cli" so resolve_auth_context grants admin permissions,
1631        // allowing the echo tool to execute through the permission check.
1632        let inbound = InboundMessage {
1633            channel: "cli".into(),
1634            sender_id: "user".into(),
1635            chat_id: "chat".into(),
1636            content: "use echo twice".into(),
1637            timestamp: chrono::Utc::now(),
1638            media: vec![],
1639            metadata: HashMap::new(),
1640        };
1641        agent.bus.publish_inbound(inbound).unwrap();
1642        let msg = agent.bus.consume_inbound().await.unwrap();
1643        agent.process_message(msg).await.unwrap();
1644
1645        let outbound = agent.bus.consume_outbound().await.unwrap();
1646        assert_eq!(outbound.content, "processed both tools");
1647
1648        // Verify the second call to the transport has both tool results
1649        let snapshots = transport_ref.snapshots();
1650        assert_eq!(snapshots.len(), 2);
1651
1652        let second_call = &snapshots[1];
1653        let tool_results: Vec<&LlmMessage> =
1654            second_call.iter().filter(|m| m.role == "tool").collect();
1655
1656        assert_eq!(
1657            tool_results.len(),
1658            2,
1659            "second call should have 2 tool result messages"
1660        );
1661
1662        // Verify each tool result has the correct call ID
1663        let ids: Vec<&str> = tool_results
1664            .iter()
1665            .filter_map(|m| m.tool_call_id.as_deref())
1666            .collect();
1667        assert!(
1668            ids.contains(&"call-mt-1"),
1669            "should have result for call-mt-1"
1670        );
1671        assert!(
1672            ids.contains(&"call-mt-2"),
1673            "should have result for call-mt-2"
1674        );
1675
1676        // Verify tool outputs
1677        let first_result = tool_results
1678            .iter()
1679            .find(|m| m.tool_call_id.as_deref() == Some("call-mt-1"))
1680            .unwrap();
1681        assert!(
1682            first_result.content.contains("first"),
1683            "first tool result should contain 'first': {}",
1684            first_result.content
1685        );
1686
1687        let second_result = tool_results
1688            .iter()
1689            .find(|m| m.tool_call_id.as_deref() == Some("call-mt-2"))
1690            .unwrap();
1691        assert!(
1692            second_result.content.contains("second"),
1693            "second tool result should contain 'second': {}",
1694            second_result.content
1695        );
1696
1697        let _ = tokio::fs::remove_dir_all(&dir).await;
1698    }
1699
1700    /// TEST-04: E2e test verifying a direct text response (no tool use)
1701    /// flows through the full pipeline correctly.
1702    #[tokio::test]
1703    async fn e2e_direct_text_response_no_tools() {
1704        let transport = Arc::new(MockTransport::new("Direct answer from LLM"));
1705        let (agent, dir) = make_agent_loop(transport, "e2e_no_tools").await;
1706
1707        let inbound = InboundMessage {
1708            channel: "direct".into(),
1709            sender_id: "user".into(),
1710            chat_id: "chat".into(),
1711            content: "what is 2+2?".into(),
1712            timestamp: chrono::Utc::now(),
1713            media: vec![],
1714            metadata: HashMap::new(),
1715        };
1716        agent.bus.publish_inbound(inbound).unwrap();
1717        let msg = agent.bus.consume_inbound().await.unwrap();
1718        agent.process_message(msg).await.unwrap();
1719
1720        let outbound = agent.bus.consume_outbound().await.unwrap();
1721        assert_eq!(outbound.content, "Direct answer from LLM");
1722        assert_eq!(outbound.channel, "direct");
1723
1724        // Session should have user + assistant
1725        let session = agent.sessions.get_or_create("direct:chat").await.unwrap();
1726        let roles: Vec<String> = session
1727            .messages
1728            .iter()
1729            .filter_map(|m| m.get("role").and_then(|v| v.as_str()).map(String::from))
1730            .collect();
1731        assert!(
1732            roles.iter().any(|r| r == "user"),
1733            "session should have user message"
1734        );
1735        assert!(
1736            roles.iter().any(|r| r == "assistant"),
1737            "session should have assistant message"
1738        );
1739
1740        let _ = tokio::fs::remove_dir_all(&dir).await;
1741    }
1742
1743    /// TEST-04: E2e test verifying tool execution failure is gracefully
1744    /// handled and the error is sent back to the LLM.
1745    struct FailingToolTransport {
1746        call_count: std::sync::atomic::AtomicUsize,
1747        recorded_requests: std::sync::Mutex<Vec<Vec<LlmMessage>>>,
1748    }
1749
1750    impl FailingToolTransport {
1751        fn new() -> Self {
1752            Self {
1753                call_count: std::sync::atomic::AtomicUsize::new(0),
1754                recorded_requests: std::sync::Mutex::new(Vec::new()),
1755            }
1756        }
1757
1758        fn snapshots(&self) -> Vec<Vec<LlmMessage>> {
1759            self.recorded_requests.lock().unwrap().clone()
1760        }
1761    }
1762
1763    #[async_trait]
1764    impl LlmTransport for FailingToolTransport {
1765        async fn complete(&self, request: &TransportRequest) -> clawft_types::Result<LlmResponse> {
1766            self.recorded_requests
1767                .lock()
1768                .unwrap()
1769                .push(request.messages.clone());
1770
1771            let count = self
1772                .call_count
1773                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1774
1775            if count == 0 {
1776                // Request a tool that does not exist
1777                Ok(LlmResponse {
1778                    id: "fail-resp-1".into(),
1779                    content: vec![ContentBlock::ToolUse {
1780                        id: "call-fail-1".into(),
1781                        name: "nonexistent_tool".into(),
1782                        input: serde_json::json!({}),
1783                    }],
1784                    stop_reason: StopReason::ToolUse,
1785                    usage: Usage {
1786                        input_tokens: 10,
1787                        output_tokens: 5,
1788                        total_tokens: 0,
1789                    },
1790                    metadata: HashMap::new(),
1791                })
1792            } else {
1793                // LLM receives the error and returns gracefully
1794                Ok(LlmResponse {
1795                    id: "fail-resp-2".into(),
1796                    content: vec![ContentBlock::Text {
1797                        text: "I see the tool failed, let me help differently".into(),
1798                    }],
1799                    stop_reason: StopReason::EndTurn,
1800                    usage: Usage {
1801                        input_tokens: 20,
1802                        output_tokens: 12,
1803                        total_tokens: 0,
1804                    },
1805                    metadata: HashMap::new(),
1806                })
1807            }
1808        }
1809    }
1810
1811    #[tokio::test]
1812    async fn e2e_tool_execution_failure_handled_gracefully() {
1813        let transport = Arc::new(FailingToolTransport::new());
1814        let transport_ref = transport.clone();
1815        let (agent, dir) = make_agent_loop(transport, "e2e_fail").await;
1816
1817        let inbound = InboundMessage {
1818            channel: "fail".into(),
1819            sender_id: "user".into(),
1820            chat_id: "chat".into(),
1821            content: "try a tool".into(),
1822            timestamp: chrono::Utc::now(),
1823            media: vec![],
1824            metadata: HashMap::new(),
1825        };
1826        agent.bus.publish_inbound(inbound).unwrap();
1827        let msg = agent.bus.consume_inbound().await.unwrap();
1828        agent.process_message(msg).await.unwrap();
1829
1830        let outbound = agent.bus.consume_outbound().await.unwrap();
1831        assert_eq!(
1832            outbound.content,
1833            "I see the tool failed, let me help differently"
1834        );
1835
1836        // Verify the error was passed to the LLM in the second call
1837        let snapshots = transport_ref.snapshots();
1838        assert_eq!(snapshots.len(), 2);
1839
1840        let second_call = &snapshots[1];
1841        let tool_result = second_call
1842            .iter()
1843            .find(|m| m.role == "tool")
1844            .expect("second call should have a tool result with the error");
1845
1846        assert!(
1847            tool_result.content.contains("error"),
1848            "tool result should contain error message: {}",
1849            tool_result.content
1850        );
1851
1852        let _ = tokio::fs::remove_dir_all(&dir).await;
1853    }
1854
1855    // ── Phase F: Auth context threading tests ────────────────────────
1856
1857    /// Helper to build an InboundMessage with the given channel and sender.
1858    fn make_inbound(channel: &str, sender_id: &str) -> InboundMessage {
1859        InboundMessage {
1860            channel: channel.into(),
1861            sender_id: sender_id.into(),
1862            chat_id: "test-chat".into(),
1863            content: "test message".into(),
1864            timestamp: chrono::Utc::now(),
1865            media: vec![],
1866            metadata: HashMap::new(),
1867        }
1868    }
1869
1870    /// Helper: resolve auth context using the default resolver (CLI=admin,
1871    /// everything else=zero_trust) without needing a full AgentLoop.
1872    fn resolve_default(msg: &InboundMessage) -> AuthContext {
1873        let resolver = PermissionResolver::default_resolver();
1874        resolver.resolve_auth_context(&msg.sender_id, &msg.channel, false)
1875    }
1876
1877    /// F-05: CLI channel gets admin-level permissions via cli_default().
1878    #[test]
1879    fn test_resolve_auth_context_cli() {
1880        let msg = make_inbound("cli", "local");
1881        let ctx = resolve_default(&msg);
1882
1883        assert_eq!(ctx.sender_id, "local");
1884        assert_eq!(ctx.channel, "cli");
1885        assert_eq!(ctx.permissions.level, 2, "CLI should get admin (level 2)");
1886        assert!(
1887            ctx.permissions.tool_access.contains(&"*".to_string()),
1888            "CLI admin should have wildcard tool access"
1889        );
1890        assert_eq!(
1891            ctx.permissions.rate_limit, 0,
1892            "CLI admin should have no rate limit"
1893        );
1894    }
1895
1896    /// F-09: Empty sender_id gets zero-trust (level 0) permissions.
1897    #[test]
1898    fn test_resolve_auth_context_empty_sender() {
1899        let msg = make_inbound("telegram", "");
1900        let ctx = resolve_default(&msg);
1901
1902        assert_eq!(ctx.sender_id, "");
1903        assert_eq!(ctx.channel, "telegram");
1904        assert_eq!(
1905            ctx.permissions.level, 0,
1906            "empty sender_id should get zero-trust (level 0)"
1907        );
1908    }
1909
1910    /// F-10: Non-CLI channel with unknown sender gets zero-trust defaults.
1911    #[test]
1912    fn test_resolve_auth_context_gateway_channel() {
1913        let msg = make_inbound("gateway", "api_key_user");
1914        let ctx = resolve_default(&msg);
1915
1916        assert_eq!(ctx.sender_id, "api_key_user");
1917        assert_eq!(ctx.channel, "gateway");
1918        assert_eq!(
1919            ctx.permissions.level, 0,
1920            "gateway users should get zero-trust (level 0) by default"
1921        );
1922        assert!(
1923            ctx.permissions.tool_access.is_empty(),
1924            "zero-trust should have no tool access"
1925        );
1926    }
1927
1928    /// F-06/07: Telegram channel gets zero-trust with sender identity preserved.
1929    /// With the default resolver (no per-user overrides), all non-CLI channels
1930    /// get zero-trust. Config-driven per-user/channel overrides are tested in
1931    /// the `permissions` module.
1932    #[test]
1933    fn test_resolve_auth_context_telegram_preserves_identity() {
1934        let msg = make_inbound("telegram", "12345");
1935        let ctx = resolve_default(&msg);
1936
1937        assert_eq!(ctx.sender_id, "12345");
1938        assert_eq!(ctx.channel, "telegram");
1939        assert_eq!(
1940            ctx.permissions.level, 0,
1941            "non-CLI channel gets zero-trust with default resolver"
1942        );
1943    }
1944
1945    /// F-extra: Discord channel gets zero-trust with sender identity preserved.
1946    #[test]
1947    fn test_resolve_auth_context_discord() {
1948        let msg = make_inbound("discord", "snowflake_987654321");
1949        let ctx = resolve_default(&msg);
1950
1951        assert_eq!(ctx.sender_id, "snowflake_987654321");
1952        assert_eq!(ctx.channel, "discord");
1953        assert_eq!(ctx.permissions.level, 0);
1954    }
1955
1956    /// F-extra: Slack channel gets zero-trust with sender identity preserved.
1957    #[test]
1958    fn test_resolve_auth_context_slack() {
1959        let msg = make_inbound("slack", "U12345");
1960        let ctx = resolve_default(&msg);
1961
1962        assert_eq!(ctx.sender_id, "U12345");
1963        assert_eq!(ctx.channel, "slack");
1964        assert_eq!(ctx.permissions.level, 0);
1965    }
1966
1967    /// F-12: process_message attaches auth_context to the pipeline request.
1968    /// Uses a "cli" channel so the auth_context has admin permissions,
1969    /// verifying the full threading from InboundMessage -> ChatRequest.
1970    #[tokio::test]
1971    async fn test_auth_context_attached_to_chat_request() {
1972        let transport = Arc::new(MockTransport::new("auth-verified"));
1973        let (agent, dir) = make_agent_loop(transport, "auth_attach").await;
1974
1975        let inbound = make_inbound("cli", "local");
1976        agent.bus.publish_inbound(inbound).unwrap();
1977
1978        let msg = agent.bus.consume_inbound().await.unwrap();
1979        agent.process_message(msg).await.unwrap();
1980
1981        // Verify response came through (proves pipeline executed successfully
1982        // with auth_context attached).
1983        let outbound = agent.bus.consume_outbound().await.unwrap();
1984        assert_eq!(outbound.content, "auth-verified");
1985        assert_eq!(outbound.channel, "cli");
1986
1987        let _ = tokio::fs::remove_dir_all(&dir).await;
1988    }
1989
1990    // ── A3: Error JSON formatting tests ────────────────────────────
1991
1992    #[test]
1993    fn error_json_escapes_double_quotes() {
1994        let error_msg = r#"file "foo" not found"#;
1995        let json_str = serde_json::json!({"error": error_msg}).to_string();
1996        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1997        assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
1998    }
1999
2000    #[test]
2001    fn error_json_escapes_backslashes() {
2002        let error_msg = r"path C:\Users\test";
2003        let json_str = serde_json::json!({"error": error_msg}).to_string();
2004        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2005        assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
2006    }
2007
2008    #[test]
2009    fn error_json_escapes_newlines() {
2010        let error_msg = "line 1\nline 2";
2011        let json_str = serde_json::json!({"error": error_msg}).to_string();
2012        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2013        assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
2014    }
2015
2016    #[test]
2017    fn error_json_escapes_all_special_chars() {
2018        let error_msg = "quote: \" backslash: \\ newline: \n tab: \t null: \0";
2019        let json_str = serde_json::json!({"error": error_msg}).to_string();
2020        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2021        assert_eq!(parsed["error"].as_str().unwrap(), error_msg);
2022    }
2023
2024    #[test]
2025    fn error_json_has_single_error_key() {
2026        let error_msg = "something went wrong";
2027        let json_str = serde_json::json!({"error": error_msg}).to_string();
2028        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2029        let obj = parsed.as_object().unwrap();
2030        assert_eq!(obj.len(), 1, "should have exactly one key");
2031        assert!(obj.contains_key("error"));
2032    }
2033
2034    /// F-12b: process_message with non-CLI channel attaches zero-trust auth_context.
2035    #[tokio::test]
2036    async fn test_auth_context_non_cli_attaches_zero_trust() {
2037        let transport = Arc::new(MockTransport::new("zero-trust-ok"));
2038        let (agent, dir) = make_agent_loop(transport, "auth_zt").await;
2039
2040        let inbound = make_inbound("telegram", "user42");
2041        agent.bus.publish_inbound(inbound).unwrap();
2042
2043        let msg = agent.bus.consume_inbound().await.unwrap();
2044        agent.process_message(msg).await.unwrap();
2045
2046        let outbound = agent.bus.consume_outbound().await.unwrap();
2047        assert_eq!(outbound.content, "zero-trust-ok");
2048        assert_eq!(outbound.channel, "telegram");
2049
2050        let _ = tokio::fs::remove_dir_all(&dir).await;
2051    }
2052
2053    // ── Auto-delegation tests ─────────────────────────────────────────
2054
2055    /// Mock auto-delegation that delegates anything containing "swarm" or "deploy".
2056    struct MockAutoDelegation;
2057
2058    impl AutoDelegation for MockAutoDelegation {
2059        fn should_delegate(&self, content: &str) -> Option<serde_json::Value> {
2060            let lower = content.to_lowercase();
2061            if lower.contains("swarm") || lower.contains("deploy") {
2062                Some(serde_json::json!({"task": content}))
2063            } else {
2064                None
2065            }
2066        }
2067    }
2068
2069    /// A tool that simulates delegate_task by returning a fixed response.
2070    struct MockDelegateTaskTool;
2071
2072    #[async_trait]
2073    impl Tool for MockDelegateTaskTool {
2074        fn name(&self) -> &str {
2075            "delegate_task"
2076        }
2077        fn description(&self) -> &str {
2078            "Mock delegate_task for testing"
2079        }
2080        fn parameters(&self) -> serde_json::Value {
2081            serde_json::json!({
2082                "type": "object",
2083                "properties": {
2084                    "task": { "type": "string" }
2085                },
2086                "required": ["task"]
2087            })
2088        }
2089        async fn execute(
2090            &self,
2091            args: serde_json::Value,
2092        ) -> Result<serde_json::Value, crate::tools::registry::ToolError> {
2093            let task = args
2094                .get("task")
2095                .and_then(|v| v.as_str())
2096                .unwrap_or("(unknown)");
2097            Ok(serde_json::json!({
2098                "status": "delegated",
2099                "target": "claude",
2100                "response": format!("Delegated: {task}"),
2101                "task": task,
2102            }))
2103        }
2104    }
2105
2106    /// Helper: create an AgentLoop with auto-delegation and a mock delegate_task tool.
2107    async fn make_auto_delegation_agent(
2108        transport: Arc<dyn LlmTransport>,
2109        prefix: &str,
2110    ) -> (AgentLoop<NativePlatform>, PathBuf) {
2111        let dir = temp_dir(prefix);
2112        let platform = Arc::new(NativePlatform::new());
2113        let bus = Arc::new(MessageBus::new());
2114
2115        let sessions_dir = dir.join("sessions");
2116        let sessions = SessionManager::with_dir(platform.clone(), sessions_dir);
2117
2118        let memory = Arc::new(MemoryStore::with_paths(
2119            dir.join("memory").join("MEMORY.md"),
2120            dir.join("memory").join("HISTORY.md"),
2121            platform.clone(),
2122        ));
2123        let skills = Arc::new(SkillsLoader::with_dir(dir.join("skills"), platform.clone()));
2124        let context = ContextBuilder::new(test_config(), memory, skills, platform.clone());
2125
2126        let mut tools = ToolRegistry::new();
2127        tools.register(Arc::new(EchoTool));
2128        tools.register(Arc::new(MockDelegateTaskTool));
2129
2130        let pipeline = make_pipeline(transport);
2131
2132        let agent = AgentLoop::new(
2133            test_config(),
2134            platform,
2135            bus,
2136            pipeline,
2137            Arc::new(tools),
2138            context,
2139            Arc::new(sessions),
2140            PermissionResolver::default_resolver(),
2141        )
2142        .with_auto_delegation(Arc::new(MockAutoDelegation));
2143
2144        (agent, dir)
2145    }
2146
2147    /// Auto-delegation kicks in when the message matches delegation rules.
2148    #[tokio::test]
2149    async fn auto_delegation_routes_matching_message() {
2150        let transport = Arc::new(MockTransport::new("should NOT see this"));
2151        let (agent, dir) = make_auto_delegation_agent(transport, "auto_del_match").await;
2152
2153        // "swarm" triggers auto-delegation
2154        let inbound = InboundMessage {
2155            channel: "cli".into(),
2156            sender_id: "local".into(),
2157            chat_id: "test".into(),
2158            content: "run a swarm security review".into(),
2159            timestamp: chrono::Utc::now(),
2160            media: vec![],
2161            metadata: HashMap::new(),
2162        };
2163        agent.bus.publish_inbound(inbound).unwrap();
2164        let msg = agent.bus.consume_inbound().await.unwrap();
2165        agent.process_message(msg).await.unwrap();
2166
2167        let outbound = agent.bus.consume_outbound().await.unwrap();
2168        assert!(
2169            outbound.content.contains("Delegated:"),
2170            "response should be from delegate_task, got: {}",
2171            outbound.content
2172        );
2173        assert!(
2174            outbound.content.contains("swarm"),
2175            "delegated response should include the original task"
2176        );
2177
2178        let _ = tokio::fs::remove_dir_all(&dir).await;
2179    }
2180
2181    /// Auto-delegation does NOT trigger for non-matching messages -- normal LLM path.
2182    #[tokio::test]
2183    async fn auto_delegation_skips_non_matching_message() {
2184        let transport = Arc::new(MockTransport::new("LLM response"));
2185        let (agent, dir) = make_auto_delegation_agent(transport, "auto_del_skip").await;
2186
2187        // "hello" does NOT match delegation rules
2188        let inbound = InboundMessage {
2189            channel: "cli".into(),
2190            sender_id: "local".into(),
2191            chat_id: "test".into(),
2192            content: "hello world".into(),
2193            timestamp: chrono::Utc::now(),
2194            media: vec![],
2195            metadata: HashMap::new(),
2196        };
2197        agent.bus.publish_inbound(inbound).unwrap();
2198        let msg = agent.bus.consume_inbound().await.unwrap();
2199        agent.process_message(msg).await.unwrap();
2200
2201        let outbound = agent.bus.consume_outbound().await.unwrap();
2202        assert_eq!(
2203            outbound.content, "LLM response",
2204            "non-matching message should go through normal LLM pipeline"
2205        );
2206
2207        let _ = tokio::fs::remove_dir_all(&dir).await;
2208    }
2209
2210    /// Without auto-delegation set, messages always go to the LLM.
2211    #[tokio::test]
2212    async fn no_auto_delegation_uses_llm() {
2213        let transport = Arc::new(MockTransport::new("normal LLM"));
2214        let (agent, dir) = make_agent_loop(transport, "no_auto_del").await;
2215
2216        // Even "swarm" goes to LLM when auto-delegation is not set
2217        let inbound = InboundMessage {
2218            channel: "cli".into(),
2219            sender_id: "local".into(),
2220            chat_id: "test".into(),
2221            content: "run a swarm task".into(),
2222            timestamp: chrono::Utc::now(),
2223            media: vec![],
2224            metadata: HashMap::new(),
2225        };
2226        agent.bus.publish_inbound(inbound).unwrap();
2227        let msg = agent.bus.consume_inbound().await.unwrap();
2228        agent.process_message(msg).await.unwrap();
2229
2230        let outbound = agent.bus.consume_outbound().await.unwrap();
2231        assert_eq!(outbound.content, "normal LLM");
2232
2233        let _ = tokio::fs::remove_dir_all(&dir).await;
2234    }
2235}