Skip to main content

recursive/
run_core.rs

1//! Stateless execution kernel — `RunCore<'a>` and `RunInnerOutcome`.
2//!
3//! Extracted from `src/agent.rs` (Goal 213).  `RunCore` owns all the state
4//! needed for one ReAct loop iteration and is consumed by `run_inner()`.
5//! The containing `AgentKernel` constructs a `RunCore`, calls `run_inner()`,
6//! and integrates the returned `RunInnerOutcome`.
7
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use tokio::sync::mpsc;
12use tokio_util::sync::CancellationToken;
13use tracing::{debug, warn};
14
15/// Output of a single tool call execution, replacing the anonymous 5-tuple.
16pub(crate) struct ToolCallOutcome {
17    pub id: String,
18    pub name: String,
19    pub result: String,
20    pub audit: Option<crate::tools::AuditMeta>,
21}
22
23/// Sentinel returned in the tool-result string when the permission denial
24/// limit is exceeded.  Matches the check in `run_inner` that breaks the
25/// ReAct loop.  Defined as a constant to avoid scattered string literals.
26pub(crate) const DENIAL_LIMIT_SENTINEL: &str = "ERROR_DENIAL_LIMIT:";
27
28use crate::compact::Compactor;
29use crate::error::Result;
30use crate::hooks::{HookAction, HookEvent, HookRegistry};
31use crate::llm::{
32    estimate_tokens, ChatProvider, Completion, ContextBreakdown, StreamChunk, StreamSender,
33    TokenUsage, ToolCall, ToolSpec,
34};
35use crate::message::Message;
36
37use crate::tools::plan_mode::{ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME};
38use crate::tools::ToolRegistry;
39
40use crate::agent::{FinishReason, PermissionDecision};
41use crate::event::AgentEvent;
42use crate::skills::Skill;
43use crate::skills_injector::SkillInjector;
44use crate::system_prompt::PromptSegments;
45use crate::tools::PermissionHook;
46
47/// Placeholder text used when trimming old tool results to fit the transcript budget.
48pub(crate) const TRIM_PLACEHOLDER: &str = "[older tool output trimmed to fit budget]";
49
50/// Minimum tool-result size (bytes) worth trimming; shorter results are kept verbatim.
51const MIN_TRIM_LENGTH: usize = 200;
52
53/// Goal-328: token estimate from a pre-computed byte count.
54///
55/// Same arithmetic as [`crate::llm::estimate_tokens`] but takes a `usize`
56/// byte-count directly so the conversation bucket can avoid re-iterating
57/// the transcript just to read each message's `len()`. Matches the public
58/// helper's ceil semantics so a 5-byte transcript chunk is 2 tokens, not 1.
59/// Byte-based (like the public helper): over-counts CJK ~3× because CJK
60/// chars are 3 UTF-8 bytes each — intentional, and keeps the breakdown
61/// buckets in the same unit as `estimate_tokens`.
62fn estimate_tokens_by_bytes(bytes: usize) -> u32 {
63    // NOTE: no `if tokens == 0 && bytes > 0 { 1 }` guard here — that branch
64    // is dead: `tokens == 0` only when `bytes == 0` (ceil(0.25..0.75)=1), so
65    // `bytes > 0` is never true when the guard could fire. Keeping it only
66    // created behavior-equivalent `>`-operator mutants.
67    (bytes as f64 / 4.0).ceil() as u32
68}
69
70/// Count one error occurrence for a tool name during stuck detection.
71///
72/// `#[mutants::skip]`: the `+= 1` mutants (`-= 1` / `*= 1`) are only
73/// behavior-different when ≥2 tools repeat in the stuck window, but
74/// `HashMap` iteration order is nondeterministic, so a test cannot pin
75/// `max_by_key`'s tie-break without being flaky. The single-repeated-tool
76/// case the counter feeds is covered by the stuck-detection integration
77/// tests.
78#[cfg_attr(test, mutants::skip)]
79fn bump_stuck_count<'a>(counts: &mut std::collections::HashMap<&'a str, usize>, name: &'a str) {
80    *counts.entry(name).or_default() += 1;
81}
82
83/// Render a [`FinishReason`] as the `reason` field of [`AgentEvent::TurnFinished`].
84///
85/// Delegates to the `Display` implementation on `FinishReason` to ensure
86/// consistency between event payloads and HTTP API responses.
87fn finish_reason_str(reason: &FinishReason) -> String {
88    reason.to_string()
89}
90
91/// Rough token estimate from message content lengths.
92///
93/// Used as fallback when the LLM provider does not report `usage` in its
94/// response. 4 chars per token is a conservative heuristic that works for
95/// mixed English + CJK text; it will slightly *under*-estimate, which is
96/// safer for the context gauge than over-estimating (false compaction
97/// warnings). Divide by at least 1 so a zero-length message list doesn't
98/// report zero tokens (the system prompt always exists).
99fn estimate_prompt_tokens(messages: &[Message]) -> u32 {
100    let total_chars: usize = messages.iter().map(|m| m.content.len()).sum();
101    (total_chars / 4).max(1) as u32
102}
103
104/// Outcome returned by the stateless [`run_inner`] loop.
105pub(crate) struct RunInnerOutcome {
106    pub(crate) messages: Arc<Vec<Message>>,
107    pub(crate) final_message: Option<String>,
108    pub(crate) finish_reason: FinishReason,
109    pub(crate) total_usage: TokenUsage,
110    pub(crate) total_llm_latency_ms: u64,
111    pub(crate) steps: usize,
112    /// Goal-153: audit metadata for tool results, keyed by `(turn, tool_call_id)`.
113    /// Only entries for successfully-dispatched tool calls are present;
114    /// the key matches `Message.tool_call_id` on `Role::Tool` messages scoped
115    /// by turn to prevent cross-turn id collisions.
116    pub(crate) tool_audits:
117        std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
118    /// `prompt_tokens` from the **last** LLM call in this turn.
119    ///
120    /// Unlike `total_usage.prompt_tokens` (which is the accumulated sum across
121    /// all LLM calls in the turn), this is the single most-recent reading from
122    /// the API response. It represents the actual current context size and is
123    /// the correct input to cross-turn compaction's `should_compact` check.
124    pub(crate) last_prompt_tokens: u32,
125}
126
127/// Private core holding all state needed for one run of the ReAct loop.
128///
129/// Borrows immutable config from the parent [`Agent`]; owns the mutable
130/// transcript and plan state.  `run_inner()` consumes `self` so the
131/// loop cannot accidentally leave stale state behind.
132pub(crate) struct RunCore<'a> {
133    pub(crate) messages: Arc<Vec<Message>>,
134    pub(crate) llm: Arc<dyn ChatProvider>,
135    pub(crate) tools: Arc<ToolRegistry>,
136    pub(crate) max_steps: usize,
137    pub(crate) max_transcript_chars: Option<usize>,
138    pub(crate) events: Option<mpsc::UnboundedSender<AgentEvent>>,
139    pub(crate) streaming: bool,
140    pub(crate) compactor: Option<Compactor>,
141    /// Goal-332: micro-compactor for intra-turn tool-result pruning.
142    /// When set, tool results are pruned at the end of each step BEFORE
143    /// the next LLM call, keeping the transcript short enough that
144    /// proactive cross-turn compaction rarely fires mid-response.
145    /// NOTE: field exists but intra-turn invocation is not yet wired
146    /// (Goal 332 added the type + field; Goal 333 wires cross-turn;
147    /// a future goal will wire intra-turn in maybe_compact).
148    #[allow(dead_code)]
149    pub(crate) microcompactor: Option<crate::compact::Microcompactor>,
150    pub(crate) permission_hook: Option<Arc<dyn PermissionHook>>,
151    pub(crate) hooks: &'a HookRegistry,
152    pub(crate) total_llm_latency_ms: u64,
153    /// Goal-165: shared flag set by `EnterPlanModeTool`; blocks write tools
154    /// while the agent is in read-only exploring / planning mode.
155    pub(crate) exploring_plan_mode: Arc<AtomicBool>,
156    /// Optional cancellation token. When cancelled, the step loop
157    /// terminates at the next step boundary with
158    /// [`FinishReason::Cancelled`].
159    pub(crate) shutdown_token: Option<CancellationToken>,
160
161    /// Optional mailbox for receiving mid-run messages from a coordinator.
162    ///
163    /// Drained at the start of every step so the agent sees coordinator
164    /// instructions as user-role messages injected into the conversation.
165    pub(crate) mailbox: Option<crate::tools::send_message::WorkerMailbox>,
166    /// Sliding window size for stuck detection (from Config).
167    pub(crate) stuck_window: usize,
168    /// Error rate threshold to declare stuck (from Config).
169    pub(crate) stuck_error_rate: f64,
170    /// Turn index (0-based), scopes [`crate::tools::AuditKey`] prefixes so
171    /// that cross-turn tool_call_id reuse cannot corrupt audit metadata.
172    pub(crate) turn: u32,
173    /// Goal-318: `Globs`-mode skills for path-triggered injection.
174    /// Injected as system messages after tool calls match a skill's glob patterns.
175    pub(crate) globs_skills: Vec<Skill>,
176    /// Goal-328: structured prompt segments from `assemble_system_prompt`,
177    /// used to size the static breakdown buckets (`system_prompt`, `rules`,
178    /// `skills`, `subagents`). `None` when the runtime was built without a
179    /// system-prompt path (tests, headless loops without prompts).
180    ///
181    /// Currently held for introspection / future hot-reload; the breakdown
182    /// computation only reads [`Self::static_breakdown`] (which was sized
183    /// at construction). The `#[allow(dead_code)]` silences the lint
184    /// without removing the field — the goal explicitly preserves the
185    /// structured segment accessor surface so callers can reason about
186    /// which buckets contribute to the prompt.
187    #[allow(dead_code)]
188    pub(crate) prompt_segments: Option<PromptSegments>,
189    /// Goal-328: per-bucket token counts for the static prompt portions,
190    /// cached once at construction so the breakdown estimator does not
191    /// re-tokenise `PromptSegments` every step. The `conversation`
192    /// bucket is recomputed every step from `self.messages`.
193    pub(crate) static_breakdown: StaticBreakdownCache,
194    /// Goal-330: `prompt_tokens` from the most recent LLM response, used
195    /// by [`Compactor::should_compact`] intra-turn. `0` means "no reading
196    /// yet" (first step, or provider never reports usage).
197    pub(crate) last_prompt_tokens: u32,
198    /// Goal-331: consecutive proactive compaction failures. Resets to 0 on
199    /// any successful compaction (including `Ok(None)` when transcript too
200    /// short is **not** a failure — only actual `Err` from the provider or
201    /// the compactor increments). When >= `MAX_CONSECUTIVE_COMPACT_FAILURES`,
202    /// subsequent proactive compaction attempts are skipped.
203    pub(crate) consecutive_compact_failures: u32,
204    /// Goal 345: optional wall-clock deadline. When
205    /// `wall_timeout_secs > 0`, the step loop checks elapsed time at
206    /// each step boundary and terminates cleanly with
207    /// [`FinishReason::WallClockExceeded`] when exceeded.
208    pub(crate) wall_timeout_secs: u64,
209    /// Wall-clock start instant, recorded when `RunCore` is
210    /// constructed. `None` when the timeout is not active.
211    pub(crate) wall_start: Option<std::time::Instant>,
212}
213
214/// Goal-328: cached token counts for the static breakdown buckets.
215///
216/// Sized once at `RunCore` construction from `PromptSegments`. The
217/// `tools` and `mcp_dynamic` buckets are also cached because they only
218/// change on a `/model` hot-swap or tool-registry change — the same
219/// hook that re-creates the runtime. `conversation` and `overhead`
220/// stay dynamic (recomputed every step).
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub(crate) struct StaticBreakdownCache {
223    pub system_prompt: u32,
224    pub rules: u32,
225    pub skills: u32,
226    pub subagents: u32,
227    /// Eager tool specs that are NOT MCP / NOT deferred.
228    pub tools: u32,
229    /// MCP / deferred tool specs.
230    pub mcp_dynamic: u32,
231}
232
233impl StaticBreakdownCache {
234    /// Build a fresh cache from a `PromptSegments` + the registry's tool
235    /// specs. `registry` is consulted to partition `eager` vs
236    /// `deferred_or_mcp` (McpTool reports `is_deferred() == true`).
237    pub(crate) fn build(
238        segments: &PromptSegments,
239        specs: &[ToolSpec],
240        registry: &ToolRegistry,
241    ) -> Self {
242        let mut tools = 0u32;
243        let mut mcp_dynamic = 0u32;
244        for spec in specs {
245            // Mirror the serde-shape the provider adapter would send: a
246            // single ToolSpec serialises to a JSON object with
247            // name/description/parameters. We tokenise that JSON text
248            // so the local estimate is comparable to what the provider
249            // actually sees after wrapping.
250            let text = serde_json::to_string(spec).unwrap_or_default();
251            let n = estimate_tokens(&text);
252            if registry.is_deferred_spec(spec) {
253                mcp_dynamic = mcp_dynamic.saturating_add(n);
254            } else {
255                tools = tools.saturating_add(n);
256            }
257        }
258        Self {
259            system_prompt: estimate_tokens(&segments.system_prompt),
260            rules: estimate_tokens(&segments.rules),
261            skills: estimate_tokens(&segments.skills),
262            subagents: estimate_tokens(&segments.subagents),
263            tools,
264            mcp_dynamic,
265        }
266    }
267}
268
269impl<'a> RunCore<'a> {
270    fn emit(&self, event: AgentEvent) {
271        if let Some(ref tx) = self.events {
272            let _ = tx.send(event);
273        }
274    }
275
276    fn push_message(&mut self, msg: Message) {
277        Arc::make_mut(&mut self.messages).push(msg);
278    }
279
280    /// Attach `reasoning_content` to the last message in the transcript, if present.
281    ///
282    /// Called immediately after `push_message` on any path that produces reasoning
283    /// tokens (both the no-tool-calls path and the tool-calls path share this logic).
284    fn attach_reasoning_content(&mut self, reasoning: Option<String>) {
285        if reasoning.is_some() {
286            if let Some(msg) = Arc::make_mut(&mut self.messages).last_mut() {
287                msg.reasoning_content = reasoning;
288            }
289        }
290    }
291
292    /// Goal-328: build a fresh [`ContextBreakdown`] from the cached static
293    /// buckets + a re-tokenised `conversation` (the transcript body).
294    /// `provider_total` is the `max(input_tokens, cache_hit + cache_miss)`
295    /// reading from the just-completed LLM call; it backs the `overhead`
296    /// bucket.
297    fn compute_breakdown(&self, provider_total: u32) -> ContextBreakdown {
298        // Conversation bucket: bytes/4 over the transcript body. We
299        // intentionally re-tokenise every step (rather than caching) so
300        // the bucket grows naturally with each new assistant / tool /
301        // user message appended this run.
302        let mut conversation_bytes: usize = 0;
303        for msg in self.messages.iter() {
304            conversation_bytes = conversation_bytes.saturating_add(msg.content.len());
305            if let Some(rc) = &msg.reasoning_content {
306                conversation_bytes = conversation_bytes.saturating_add(rc.len());
307            }
308        }
309        // `estimate_tokens` uses (bytes as f64 / 4.0).ceil() as u32.
310        let conversation = estimate_tokens_by_bytes(conversation_bytes);
311
312        let local_sum = self
313            .static_breakdown
314            .system_prompt
315            .saturating_add(self.static_breakdown.rules)
316            .saturating_add(self.static_breakdown.skills)
317            .saturating_add(self.static_breakdown.subagents)
318            .saturating_add(self.static_breakdown.tools)
319            .saturating_add(self.static_breakdown.mcp_dynamic)
320            .saturating_add(conversation);
321        let overhead = provider_total.saturating_sub(local_sum);
322
323        ContextBreakdown {
324            system_prompt: self.static_breakdown.system_prompt,
325            rules: self.static_breakdown.rules,
326            skills: self.static_breakdown.skills,
327            subagents: self.static_breakdown.subagents,
328            tools: self.static_breakdown.tools,
329            mcp_dynamic: self.static_breakdown.mcp_dynamic,
330            conversation,
331            overhead,
332        }
333    }
334
335    /// Goal-328: emit the [`AgentEvent::ContextBreakdown`] event for `step`.
336    /// `usage` is the provider's reported truth for this step.
337    fn emit_breakdown(&self, step: usize, usage: &TokenUsage) {
338        // Match `UsageStats::record_with_cache`'s logic:
339        //   max(input_tokens, cache_hit + cache_miss)
340        // so Anthropic (cache_hit excludes input_tokens) and OpenAI
341        // (cache_hit == 0, input_tokens is the full prompt) both feed
342        // a sensible `provider_total`.
343        let cache_sum = usage
344            .cache_hit_tokens
345            .saturating_add(usage.cache_miss_tokens);
346        let provider_total = usage.prompt_tokens.max(cache_sum);
347        let breakdown = self.compute_breakdown(provider_total);
348        self.emit(AgentEvent::ContextBreakdown { breakdown, step });
349    }
350
351    /// Execute one LLM call for this step and surface everything the
352    /// loop body needs: stream-forwarding task, latency tracking,
353    /// token-usage accumulation, and the ordered `Reasoning` /
354    /// `AssistantText` events.
355    ///
356    /// Returns the raw [`Completion`] plus `Some(text)` when the step
357    /// produced non-empty assistant content (which becomes the
358    /// candidate `final_message`). The caller decides what to do with
359    /// the completion (no-tool-calls path vs. tool-dispatch path) and
360    /// owns pushing assistant messages onto the transcript —
361    /// `dispatch_llm_step` deliberately does not mutate `self.messages`.
362    async fn dispatch_llm_step(
363        &mut self,
364        specs: &[crate::llm::ToolSpec],
365        step: usize,
366        total_usage: &mut TokenUsage,
367    ) -> crate::error::Result<(Completion, Option<String>)> {
368        debug!(target: "recursive::agent", step, "calling llm");
369        let start = std::time::Instant::now();
370        // Spin up the partial-token forwarder when streaming so the
371        // UI gets live `PartialToken` / `PartialReasoning` events.
372        let mut forward_handle = None;
373        let stream_tx: Option<StreamSender> = if self.streaming {
374            let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<StreamChunk>();
375            let events_tx = self.events.clone();
376            forward_handle = Some(tokio::spawn(async move {
377                while let Some(chunk) = delta_rx.recv().await {
378                    if let Some(ref tx) = events_tx {
379                        let event = match chunk {
380                            StreamChunk::Text(text) => AgentEvent::PartialToken { text, step },
381                            StreamChunk::Reasoning(text) => {
382                                AgentEvent::PartialReasoning { text, step }
383                            }
384                        };
385                        let _ = tx.send(event);
386                    }
387                }
388            }));
389            Some(delta_tx)
390        } else {
391            None
392        };
393        let mut completion: Completion = self.call_llm(specs, stream_tx).await?;
394        // Drain the partial-token forwarder before emitting any further
395        // events. `call_llm` drops `stream_tx` on return, which closes
396        // `delta_rx` and lets the spawned task finish; awaiting it
397        // guarantees every `PartialToken` has been pushed to the event
398        // sink *before* the finalising `AssistantText`. Without this the
399        // two tasks race on the shared sink and a late token can arrive
400        // after the assistant block was finalised, spawning a duplicate
401        // streaming block in the UI.
402        if let Some(handle) = forward_handle.take() {
403            let _ = handle.await;
404        }
405        // Normalise chain-of-thought that the model emitted inline as
406        // `<think>…</think>` in `content` (common for OpenAI-compatible
407        // DeepSeek-R1 deployments that don't use the dedicated
408        // `reasoning_content` SSE field) into the reasoning channel, so
409        // the thinking block renders instead of being dropped by the
410        // markdown HTML-block parser.
411        completion.extract_inline_reasoning();
412        let llm_ms = start.elapsed().as_millis() as u64;
413        self.total_llm_latency_ms = self.total_llm_latency_ms.saturating_add(llm_ms);
414        self.emit(AgentEvent::Latency { step, llm_ms });
415
416        if let Some(u) = completion.usage {
417            *total_usage = total_usage.accumulate(u);
418            self.last_prompt_tokens = u.prompt_tokens;
419            self.emit(AgentEvent::Usage {
420                input_tokens: u.prompt_tokens,
421                output_tokens: u.completion_tokens,
422                cache_hit_tokens: u.cache_hit_tokens,
423                cache_miss_tokens: u.cache_miss_tokens,
424                step,
425            });
426            // Goal-328: emit the local per-component breakdown right after
427            // the provider's `Usage` truth. The `overhead` bucket absorbs
428            // the difference between our 7-bucket local sum and the
429            // provider's reported `prompt_tokens` (computed as
430            // `max(input_tokens, cache_hit + cache_miss)` so Anthropic
431            // and OpenAI reporting differences are handled).
432            self.emit_breakdown(step, &u);
433        } else {
434            // Provider did not report usage — emit a best-effort estimate
435            // so the TUI context gauge still updates on every turn.
436            let estimated_input = estimate_prompt_tokens(&self.messages);
437            let output_len = completion.content.len()
438                + completion.reasoning_content.as_ref().map_or(0, |s| s.len());
439            let estimated_output = (output_len / 4).max(1) as u32;
440            self.emit(AgentEvent::Usage {
441                input_tokens: estimated_input,
442                output_tokens: estimated_output,
443                cache_hit_tokens: 0,
444                cache_miss_tokens: 0,
445                step,
446            });
447        }
448
449        // Surface reasoning / thinking content to UI consumers (TUI) as
450        // a separate event so it can be rendered as a `thinking…` block.
451        // Providers that stream reasoning tokens accumulate them into
452        // the final string before this point; we emit exactly once per
453        // step. Emit this BEFORE AssistantText so the TUI's
454        // `Reasoning { text }` block lands above the matching
455        // `Assistant { text }` block — the model thinks first, then
456        // speaks, and the visual order should match.
457        if let Some(reasoning) = &completion.reasoning_content {
458            if !reasoning.is_empty() {
459                self.emit(AgentEvent::Reasoning {
460                    text: reasoning.clone(),
461                    step,
462                });
463            }
464        }
465
466        let new_final_message = if !completion.content.is_empty() {
467            self.emit(AgentEvent::AssistantText {
468                text: completion.content.clone(),
469                step,
470            });
471            Some(completion.content.clone())
472        } else {
473            None
474        };
475
476        Ok((completion, new_final_message))
477    }
478
479    /// Process the result of a tool batch: emit per-call `ToolResult`
480    /// events, push paired tool_result messages, run sliding-window
481    /// stuck detection, then optionally terminate the run.
482    ///
483    /// Returns `Some(finish)` when the run should end after this batch
484    /// (sentinel pre-pass hits `PermissionDenialLimit`, or stuck
485    /// detection triggers `Stuck`); the caller routes the finish through
486    /// [`make_outcome`]. Returns `None` when the loop should continue.
487    ///
488    /// **Invariant #8**: every tool_call in the batch is paired with a
489    /// tool_result message before any early return. The sentinel pre-pass
490    /// scans the whole batch first and flushes all tool_results
491    /// atomically; the stuck-detection loop likewise pushes each
492    /// tool_result before recording the (deferred) finish verdict. We
493    /// never mid-batch return.
494    ///
495    /// When returning `None`, also runs the Globs-mode skill injector
496    /// against the result strings so the next step can pick up
497    /// path-triggered skills.
498    #[allow(clippy::too_many_arguments)]
499    fn process_tool_results(
500        &mut self,
501        results: &[ToolCallOutcome],
502        step: usize,
503        recent_errors: &mut std::collections::VecDeque<(bool, String)>,
504        tool_audits: &mut std::collections::HashMap<
505            crate::tools::AuditKey,
506            crate::tools::AuditMeta,
507        >,
508        skill_injector: &mut SkillInjector,
509    ) -> Option<FinishReason> {
510        // Goal-285: sentinel pre-pass — detect before any push_message calls.
511        // When the sentinel appears at index N > 0, flushing incrementally
512        // would duplicate results[0..N] in the transcript; scan first, flush
513        // once atomically, return immediately.
514        if results.iter().any(|o| o.result == DENIAL_LIMIT_SENTINEL) {
515            for o in results {
516                if let Some(a) = &o.audit {
517                    tool_audits.insert((self.turn, o.id.clone()), a.clone());
518                }
519                let is_error = o.result.starts_with("ERROR: ") || o.result == DENIAL_LIMIT_SENTINEL;
520                self.emit(AgentEvent::ToolResult {
521                    id: o.id.clone(),
522                    name: o.name.clone(),
523                    output: o.result.clone(),
524                    step,
525                    is_error,
526                });
527                self.push_message(Message::tool_result(o.id.clone(), o.result.clone()));
528            }
529            let finish = FinishReason::PermissionDenialLimit;
530            self.emit(AgentEvent::TurnFinished {
531                reason: finish_reason_str(&finish),
532                steps: step,
533            });
534            return Some(finish);
535        }
536
537        // Stuck detection may fire while iterating the results of a
538        // multi-call step. Returning mid-loop (as this code used to do)
539        // left the assistant message's remaining tool_calls without
540        // matching tool_result messages — orphaned `tool_use` blocks
541        // that the provider rejects on the *next* turn with HTTP 400.
542        // Record the stuck verdict here but defer the actual return
543        // until the loop has pushed a tool_result for every call.
544        let mut stuck_finish: Option<FinishReason> = None;
545        for ToolCallOutcome {
546            id,
547            name,
548            result,
549            audit,
550        } in results
551        {
552            let is_error = result.starts_with("ERROR: ");
553            self.emit(AgentEvent::ToolResult {
554                id: id.clone(),
555                name: name.clone(),
556                output: result.clone(),
557                step,
558                is_error,
559            });
560            if let Some(a) = audit {
561                tool_audits.insert((self.turn, id.clone()), a.clone());
562            }
563            self.push_message(Message::tool_result(id.clone(), result.clone()));
564
565            // Sliding-window stuck detection: track whether each tool call
566            // was an error. Triggers when the error rate in the last
567            // stuck_window steps exceeds stuck_error_rate, catching loops
568            // that cycle across different tools (e.g. A→B→A→B).
569            if recent_errors.len() == self.stuck_window {
570                recent_errors.pop_front();
571            }
572            recent_errors.push_back((is_error, name.clone()));
573
574            if stuck_finish.is_none() && recent_errors.len() == self.stuck_window {
575                let error_entries: Vec<&str> = recent_errors
576                    .iter()
577                    .filter(|(is_err, _)| *is_err)
578                    .map(|(_, n)| n.as_str())
579                    .collect();
580                let error_count = error_entries.len();
581                let rate = error_count as f64 / self.stuck_window as f64;
582                if rate >= self.stuck_error_rate {
583                    let mut counts: std::collections::HashMap<&str, usize> =
584                        std::collections::HashMap::new();
585                    for n in &error_entries {
586                        bump_stuck_count(&mut counts, n);
587                    }
588                    let top_tool = counts
589                        .into_iter()
590                        .max_by_key(|&(_, c)| c)
591                        .map(|(n, _)| n)
592                        .unwrap_or(name.as_str());
593                    stuck_finish = Some(FinishReason::Stuck {
594                        repeated_call: top_tool.to_string(),
595                        repeats: error_count,
596                    });
597                }
598            }
599        }
600
601        // Deferred stuck termination: every tool_call now has its matching
602        // tool_result in the transcript, so ending the turn here cannot
603        // leave orphaned `tool_use` blocks behind.
604        if let Some(finish) = stuck_finish {
605            self.emit(AgentEvent::TurnFinished {
606                reason: finish_reason_str(&finish),
607                steps: step,
608            });
609            return Some(finish);
610        }
611
612        // Goal-318: after every tool-result batch, check whether any result
613        // references a path matching a Globs-mode skill. Inject once per skill.
614        let result_strings: Vec<String> = results.iter().map(|r| r.result.clone()).collect();
615        for (skill_name, skill_body) in skill_injector.check(&result_strings) {
616            self.push_message(Message::system(format!(
617                "<!-- skill:{skill_name} injected by globs match -->\n{skill_body}"
618            )));
619        }
620        None
621    }
622
623    /// Finalise a step whose LLM completion carried no tool calls. Pushes
624    /// the assistant message + reasoning onto the transcript, classifies
625    /// the finish reason (`ProviderStop` for non-`stop`/`end_turn`
626    /// provider reasons, else `NoMoreToolCalls`), emits `TurnFinished`,
627    /// and returns `Some(finish)` so the caller routes through
628    /// [`make_outcome`]. Returns `None` when the caller should continue
629    /// with tool dispatch.
630    fn handle_no_tool_calls(
631        &mut self,
632        completion: &Completion,
633        step: usize,
634    ) -> Option<FinishReason> {
635        if !completion.tool_calls.is_empty() {
636            return None;
637        }
638        self.push_message(Message::assistant(completion.content.clone()));
639        self.attach_reasoning_content(completion.reasoning_content.clone());
640        let finish = match completion.finish_reason.as_deref() {
641            Some(r) if r != "stop" && r != "end_turn" => FinishReason::ProviderStop(r.to_string()),
642            _ => FinishReason::NoMoreToolCalls,
643        };
644        self.emit(AgentEvent::TurnFinished {
645            reason: finish_reason_str(&finish),
646            steps: step,
647        });
648        Some(finish)
649    }
650
651    /// Drain any messages that a coordinator pushed via `send_message`
652    /// since the last step, appending each as a user-role turn so the
653    /// LLM sees coordinator instructions on the next reasoning step.
654    /// No-op when no mailbox is configured.
655    async fn drain_mailbox(&mut self) {
656        let Some(mailbox) = self.mailbox.as_ref() else {
657            return;
658        };
659        let pending = mailbox.drain_all().await;
660        for msg_text in pending {
661            self.push_message(Message {
662                role: crate::message::Role::User,
663                content: format!("[coordinator]: {msg_text}"),
664                tool_calls: vec![],
665                tool_call_id: None,
666                reasoning_content: None,
667                is_compaction_summary: false,
668            });
669        }
670    }
671
672    /// Apply transcript-budget enforcement for this step. When configured
673    /// (`max_transcript_chars`) the transcript is trimmed and re-measured;
674    /// if it still exceeds the limit, returns `Some((finish, step))` for
675    /// the caller to route through [`make_outcome`].
676    fn enforce_transcript_budget(
677        &mut self,
678        step: usize,
679        total_usage: &TokenUsage,
680    ) -> Option<(FinishReason, usize)> {
681        let limit = self.max_transcript_chars?;
682        self.maybe_trim_transcript(limit, step);
683        let chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
684        if chars < limit {
685            return None;
686        }
687        let finish = FinishReason::TranscriptLimit { chars, limit };
688        self.emit(AgentEvent::TurnFinished {
689            reason: finish_reason_str(&finish),
690            steps: step,
691        });
692        tracing::info!(
693            target: "recursive::agent",
694            steps = step,
695            tokens_in = total_usage.prompt_tokens,
696            tokens_out = total_usage.completion_tokens,
697            finish = ?finish,
698            llm_latency_ms = self.total_llm_latency_ms,
699            "agent.run.complete"
700        );
701        Some((finish, step))
702    }
703
704    /// Check the shutdown token at the top of a step. Returns `Some((
705    /// finish, finished_steps))` when the run should terminate with
706    /// [`FinishReason::Cancelled`]; the caller routes the pair through
707    /// [`make_outcome`]. `finished_steps` is `step - 1` because the
708    /// current step's LLM call has not started yet.
709    ///
710    /// `total_usage` is read for the structured log line only — the
711    /// outcome assembly happens at the call site.
712    fn check_shutdown(
713        &self,
714        step: usize,
715        total_usage: &TokenUsage,
716    ) -> Option<(FinishReason, usize)> {
717        let token = self.shutdown_token.as_ref()?;
718        if !token.is_cancelled() {
719            return None;
720        }
721        let finished_steps = step.saturating_sub(1);
722        let finish = FinishReason::Cancelled;
723        self.emit(AgentEvent::TurnFinished {
724            reason: finish_reason_str(&finish),
725            steps: finished_steps,
726        });
727        tracing::info!(
728            target: "recursive::agent",
729            steps = finished_steps,
730            tokens_in = total_usage.prompt_tokens,
731            tokens_out = total_usage.completion_tokens,
732            finish = ?finish,
733            llm_latency_ms = self.total_llm_latency_ms,
734            "agent.run.complete"
735        );
736        Some((finish, finished_steps))
737    }
738
739    /// Goal 345: check the wall-clock deadline at the top of a step.
740    /// Returns `Some((finish, finished_steps))` when the deadline has
741    /// been exceeded; the caller routes through [`make_outcome`].
742    fn check_wall_deadline(
743        &self,
744        step: usize,
745        total_usage: &TokenUsage,
746    ) -> Option<(FinishReason, usize)> {
747        if self.wall_timeout_secs == 0 {
748            return None;
749        }
750        let elapsed = self.wall_start?.elapsed();
751        if elapsed.as_secs() < self.wall_timeout_secs {
752            return None;
753        }
754        let finished_steps = step.saturating_sub(1);
755        let finish = FinishReason::WallClockExceeded {
756            secs: self.wall_timeout_secs,
757        };
758        self.emit(AgentEvent::TurnFinished {
759            reason: finish_reason_str(&finish),
760            steps: finished_steps,
761        });
762        tracing::info!(
763            target: "recursive::agent",
764            steps = finished_steps,
765            tokens_in = total_usage.prompt_tokens,
766            tokens_out = total_usage.completion_tokens,
767            finish = ?finish,
768            llm_latency_ms = self.total_llm_latency_ms,
769            "agent.run.complete (wall clock)"
770        );
771        Some((finish, finished_steps))
772    }
773
774    /// Assemble the final outcome for a run that terminates with `finish`
775    /// at step `steps`. All early-return paths in [`run_inner`] route here
776    /// so the eight-field struct cannot drift out of sync across sites.
777    fn make_outcome(
778        self,
779        finish: FinishReason,
780        steps: usize,
781        final_message: Option<String>,
782        total_usage: TokenUsage,
783        tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
784    ) -> RunInnerOutcome {
785        RunInnerOutcome {
786            messages: self.messages,
787            final_message,
788            finish_reason: finish,
789            total_usage,
790            total_llm_latency_ms: self.total_llm_latency_ms,
791            steps,
792            tool_audits,
793            last_prompt_tokens: self.last_prompt_tokens,
794        }
795    }
796
797    /// Goal 353 — translate a mid-stream [`Error::Cancelled`] from the
798    /// in-flight LLM call into a [`FinishReason::Cancelled`] outcome so the
799    /// caller persists the partial transcript (Invariant #7).
800    ///
801    /// `finished_steps` is `step` — NOT `step - 1` like [`check_shutdown`]:
802    /// the cancellation happened *during* the current step's LLM call, so the
803    /// step is in-progress, not pre-call. The caller passes the loop's `step`
804    /// verbatim; this helper deliberately does not compute it, keeping the
805    /// step-vs-step-1 distinction visible at the call site. The event and log
806    /// mirror [`check_shutdown`]'s so observers see Cancelled consistently.
807    fn make_cancelled_outcome(
808        self,
809        step: usize,
810        final_message: Option<String>,
811        total_usage: TokenUsage,
812        tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
813    ) -> RunInnerOutcome {
814        let finish = FinishReason::Cancelled;
815        self.emit(AgentEvent::TurnFinished {
816            reason: finish_reason_str(&finish),
817            steps: step,
818        });
819        tracing::info!(
820            target: "recursive::agent",
821            steps = step,
822            finish = ?finish,
823            "agent.run.cancelled_mid_stream"
824        );
825        self.make_outcome(finish, step, final_message, total_usage, tool_audits)
826    }
827
828    /// Goal 382 — route a stream-interrupted completion to the Cancelled
829    /// outcome. The SSE parsers (openai.rs / anthropic.rs) now return
830    /// `Ok(Completion { finish_reason: Some("interrupted"), .. })` instead
831    /// of `Err` when a network drop or user-initiated cancel cuts the
832    /// stream after content arrived. This pushes the partial assistant
833    /// message onto the transcript and finalises the turn as
834    /// [`FinishReason::Cancelled`] via [`Self::make_cancelled_outcome`], so
835    /// the caller's `emit_turn_messages` persists it (Invariant #7:
836    /// transcript saved before the CLI maps the reason to an exit code).
837    /// Kept as a sibling helper to protect Invariant #1 (loop body size).
838    fn finish_interrupted(
839        mut self,
840        completion: Completion,
841        step: usize,
842        total_usage: TokenUsage,
843        tool_audits: std::collections::HashMap<crate::tools::AuditKey, crate::tools::AuditMeta>,
844    ) -> RunInnerOutcome {
845        self.push_message(Message::assistant(completion.content.clone()));
846        self.attach_reasoning_content(completion.reasoning_content.clone());
847        self.make_cancelled_outcome(
848            step,
849            Some(completion.content), // partial reply becomes final_message
850            total_usage,
851            tool_audits,
852        )
853    }
854
855    /// Call the LLM once, delegating retry handling to the provider's
856    /// internal `RetryPolicy`. Goal-288 removed the outer retry loop so
857    /// there is exactly one retry layer.
858    ///
859    /// When the registry has deferred tools, their names are injected as an
860    /// `<available-deferred-tools>` user message prepended to the transcript,
861    /// and only eager tool specs are passed to the provider. ToolSearchTool
862    /// (registered by `freeze_deferred_specs`) appears in the eager list and
863    /// its results in the message history are serialized as `tool_reference`
864    /// blocks by `serialize_messages_anthropic`.
865    /// Inject the skill catalog as a `<system-reminder>` user turn, appended
866    /// to the **tail** of the per-request message copy.
867    ///
868    /// Why tail (not head): the catalog is volatile — it changes when skills
869    /// load/unload. Placing it at the head (right after the leading `system`
870    /// message, as an earlier draft did) busts the prefix cache for every
871    /// message that follows it on each skill change. At the tail it sits in
872    /// the most-recent turn, mirroring fake-cc's `skill_listing` attachment,
873    /// which `reorderAttachmentsForAPI` bubbles to the end of the
874    /// conversation. The static `system` field is untouched either way, so
875    /// prefix caching of the system prompt + stable history is preserved.
876    ///
877    /// Correctness: appending a `user` turn at the tail is safe on both
878    /// Anthropic and OpenAI-compatible providers. It never splits an
879    /// assistant→tool_result pair (we only append, never insert), so
880    /// AGENTS.md invariant #8 holds. The transcript (`self.messages`) is
881    /// never mutated — only the per-request copy is wrapped.
882    fn inject_skill_reminder(
883        messages: &[crate::message::Message],
884        skills: &[crate::skills::Skill],
885    ) -> Vec<crate::message::Message> {
886        if skills.is_empty() {
887            return messages.to_vec();
888        }
889        let reminder = crate::skills::skill_reminder(skills);
890        let mut out = Vec::with_capacity(messages.len() + 1);
891        out.extend(messages.iter().cloned());
892        out.push(crate::message::Message::user(reminder));
893        out
894    }
895
896    async fn call_llm(
897        &self,
898        specs: &[crate::llm::ToolSpec],
899        stream_sender: Option<crate::llm::StreamSender>,
900    ) -> crate::error::Result<Completion> {
901        // Deferred-tool partition: only when the provider supports tool_reference
902        // (Anthropic). Other providers (OpenAI-compatible) receive all tools eagerly
903        // — ToolSearchTool is not registered for them (see AgentRuntimeBuilder::build).
904        let eager_specs_owned: Vec<crate::llm::ToolSpec>;
905        let messages_with_deferred: Vec<crate::message::Message>;
906
907        let (call_specs, messages): (&[crate::llm::ToolSpec], &[crate::message::Message]) =
908            if self.llm.supports_deferred_tools() {
909                let (eager, deferred): (Vec<_>, Vec<_>) = specs
910                    .iter()
911                    .cloned()
912                    .partition(|s| !self.tools.is_deferred_spec(s));
913
914                if deferred.is_empty() {
915                    (specs, &self.messages)
916                } else {
917                    let names: Vec<&str> = deferred.iter().map(|s| s.name.as_str()).collect();
918                    let block = format!(
919                        "<available-deferred-tools>\n{}\n</available-deferred-tools>",
920                        names.join("\n")
921                    );
922                    messages_with_deferred = std::iter::once(crate::message::Message::user(block))
923                        .chain(self.messages.iter().cloned())
924                        .collect();
925                    eager_specs_owned = eager;
926                    (&eager_specs_owned, &messages_with_deferred)
927                }
928            } else {
929                (specs, &self.messages)
930            };
931
932        // Ship the skill catalog per-turn as a tail-appended `system-reminder`
933        // so the static `system` prompt + stable history stay cacheable. Does
934        // not mutate the transcript. See `inject_skill_reminder` for rationale.
935        let injected: Vec<crate::message::Message> =
936            Self::inject_skill_reminder(messages, &self.globs_skills);
937
938        if let Some(ref tx) = stream_sender {
939            let cancel_token = self.shutdown_token.clone();
940            self.llm
941                .stream(&injected, call_specs, Some(tx.clone()), cancel_token)
942                .await
943        } else {
944            self.llm.complete(&injected, call_specs).await
945        }
946    }
947
948    /// Trim old tool results to fit the transcript under `limit` chars.
949    fn maybe_trim_transcript(&mut self, limit: usize, step: usize) {
950        let mut chars: usize = self.messages.iter().map(|m| m.content.len()).sum();
951        if chars < limit {
952            return;
953        }
954
955        let mut trimmed_count: usize = 0;
956        let placeholder_len = TRIM_PLACEHOLDER.len();
957
958        for msg in Arc::make_mut(&mut self.messages).iter_mut().skip(1) {
959            if msg.role == crate::message::Role::Tool && msg.content.len() > MIN_TRIM_LENGTH {
960                let old_len = msg.content.len();
961                msg.content = TRIM_PLACEHOLDER.to_string();
962                trimmed_count += 1;
963                chars = chars
964                    .saturating_sub(old_len)
965                    .saturating_add(placeholder_len);
966                if chars < limit {
967                    break;
968                }
969            }
970        }
971
972        if trimmed_count > 0 {
973            let note = format!(
974                "[trimmed {} old tool result{} to fit budget]",
975                trimmed_count,
976                if trimmed_count == 1 { "" } else { "s" }
977            );
978            self.emit(AgentEvent::AssistantText { text: note, step });
979        }
980    }
981
982    /// Compact the transcript if a [`Compactor`] is configured and the
983    /// character budget is exceeded.
984    ///
985    /// `PreCompact` and `PostCompact` hooks are dispatched only when
986    /// compaction actually runs (threshold is exceeded), not on every step.
987    ///
988    /// **Best-effort**: compaction failures are caught, logged, and counted
989    /// by the circuit breaker. A failed compaction NEVER propagates to the
990    /// caller — compaction is an optimization, not a correctness requirement.
991    async fn maybe_compact(&mut self, step: usize) {
992        let compactor = match &self.compactor {
993            Some(c) => c,
994            None => return,
995        };
996
997        // Circuit breaker: stop trying after too many consecutive failures.
998        if self.consecutive_compact_failures >= crate::compact::MAX_CONSECUTIVE_COMPACT_FAILURES {
999            self.emit(AgentEvent::CompactionSkipped {
1000                step,
1001                reason: crate::event::CompactionSkipReason::CircuitBreaker,
1002            });
1003            return;
1004        }
1005
1006        let bytes = Compactor::estimate_bytes(&self.messages);
1007        if !compactor.should_compact(bytes, self.last_prompt_tokens) {
1008            return;
1009        }
1010        // Goal 345: only dispatch PreCompact when compaction will actually run.
1011        // `would_compact` mirrors `apply_to_transcript`'s degenerate-slice
1012        // rejection so PreCompact / PostCompact stay balanced (both fire, or
1013        // neither does) — without this the new Ok(None) path fired PreCompact
1014        // without a matching PostCompact.
1015        if !compactor.would_compact(&self.messages) {
1016            return;
1017        }
1018
1019        // Only dispatch PreCompact when we're actually about to compact.
1020        self.hooks.dispatch(HookEvent::PreCompact {
1021            transcript_len: bytes,
1022        });
1023
1024        let kept_before = self.messages.len();
1025        match compactor
1026            .apply_to_transcript(self.llm.as_ref(), Arc::make_mut(&mut self.messages), step)
1027            .await
1028        {
1029            Ok(Some((removed, summary_chars))) => {
1030                // Success — reset the circuit breaker.
1031                self.consecutive_compact_failures = 0;
1032                let kept = kept_before - removed;
1033                self.hooks.dispatch(HookEvent::PostCompact {
1034                    removed,
1035                    summary_chars,
1036                });
1037                self.emit(AgentEvent::Compacted {
1038                    removed,
1039                    kept,
1040                    summary_chars,
1041                    step,
1042                });
1043            }
1044            Ok(None) => {
1045                // Transcript too short to compact — not a failure, leave counter unchanged.
1046            }
1047            Err(e) => {
1048                // Compaction failed — increment the breaker, emit event, continue.
1049                tracing::warn!(error = %e, "proactive compaction failed");
1050                self.consecutive_compact_failures += 1;
1051                self.emit(AgentEvent::CompactionSkipped {
1052                    step,
1053                    reason: crate::event::CompactionSkipReason::Error,
1054                });
1055            }
1056        }
1057    }
1058
1059    /// Execute a set of tool calls, returning `(id, name, output, args)` for each.
1060    /// Read-only calls are batched and executed in parallel; write calls run
1061    /// sequentially to preserve ordering guarantees.
1062    async fn execute_tool_calls(&self, calls: &[ToolCall]) -> Vec<ToolCallOutcome> {
1063        let mut results: Vec<ToolCallOutcome> = Vec::new();
1064
1065        struct PendingCall {
1066            id: String,
1067            name: String,
1068            args: serde_json::Value,
1069        }
1070        let mut pending: Vec<PendingCall> = Vec::new();
1071
1072        for call in calls {
1073            // Goal-165: while in agent-driven plan mode, block any write tool
1074            // that is not `exit_plan_mode` itself.
1075            if self.exploring_plan_mode.load(Ordering::Relaxed)
1076                && !self.tools.is_readonly(&call.name)
1077                && call.name != EXIT_PLAN_MODE_TOOL_NAME
1078            {
1079                results.push(ToolCallOutcome {
1080                    id: call.id.clone(),
1081                    name: call.name.clone(),
1082                    result: format!(
1083                        "ERROR: Cannot execute '{}' in plan mode. \
1084                         You are in read-only planning mode. \
1085                         Explore freely with read tools, then call \
1086                         exit_plan_mode with your plan.",
1087                        call.name
1088                    ),
1089                    audit: None,
1090                });
1091                continue;
1092            }
1093
1094            // Goal-190: if a tool is in Plan mode and we're not in plan mode,
1095            // tell the agent to enter plan mode first.
1096            if !self.exploring_plan_mode.load(Ordering::Relaxed)
1097                && self.tools.is_plan_mode(&call.name)
1098                && call.name != ENTER_PLAN_MODE_TOOL_NAME
1099                && call.name != EXIT_PLAN_MODE_TOOL_NAME
1100            {
1101                results.push(ToolCallOutcome {
1102                    id: call.id.clone(),
1103                    name: call.name.clone(),
1104                    result: format!(
1105                        "ERROR: Tool '{}' requires plan mode. \
1106                         Call enter_plan_mode first to explore and plan, \
1107                         then call exit_plan_mode with your plan before using this tool.",
1108                        call.name
1109                    ),
1110                    audit: None,
1111                });
1112                continue;
1113            }
1114
1115            let effective_args = if let Some(ref hook) = self.permission_hook {
1116                match hook.check(&call.name, &call.arguments).await {
1117                    PermissionDecision::Allow => call.arguments.clone(),
1118                    PermissionDecision::Deny(reason) => {
1119                        let result = format!("ERROR: {reason}");
1120                        results.push(ToolCallOutcome {
1121                            id: call.id.clone(),
1122                            name: call.name.clone(),
1123                            result,
1124                            audit: None,
1125                        });
1126                        continue;
1127                    }
1128                    PermissionDecision::Transform(new_args) => new_args,
1129                }
1130            } else {
1131                call.arguments.clone()
1132            };
1133
1134            let hook_action = self.hooks.dispatch(HookEvent::PreToolCall {
1135                name: &call.name,
1136                args: &effective_args,
1137            });
1138            match hook_action {
1139                HookAction::Skip => {
1140                    let result = "ERROR: tool call skipped by hook".to_string();
1141                    results.push(ToolCallOutcome {
1142                        id: call.id.clone(),
1143                        name: call.name.clone(),
1144                        result,
1145                        audit: None,
1146                    });
1147                    continue;
1148                }
1149                HookAction::Error(msg) => {
1150                    let result = format!("ERROR: {msg}");
1151                    results.push(ToolCallOutcome {
1152                        id: call.id.clone(),
1153                        name: call.name.clone(),
1154                        result,
1155                        audit: None,
1156                    });
1157                    continue;
1158                }
1159                HookAction::Continue => {}
1160            }
1161
1162            pending.push(PendingCall {
1163                id: call.id.clone(),
1164                name: call.name.clone(),
1165                args: effective_args,
1166            });
1167        }
1168
1169        let mut i = 0;
1170        while i < pending.len() {
1171            if self
1172                .tools
1173                .is_readonly_for_call(&pending[i].name, &pending[i].args)
1174            {
1175                let batch_start = i;
1176                while i < pending.len()
1177                    && self
1178                        .tools
1179                        .is_readonly_for_call(&pending[i].name, &pending[i].args)
1180                {
1181                    i += 1;
1182                }
1183                let batch: Vec<PendingCall> = pending.drain(batch_start..i).collect();
1184                i = batch_start;
1185
1186                let mut join_set = tokio::task::JoinSet::new();
1187                for pc in &batch {
1188                    let name = pc.name.clone();
1189                    let id = pc.id.clone();
1190                    let args = pc.args.clone();
1191                    let tools = Arc::clone(&self.tools);
1192                    join_set.spawn(async move {
1193                        let tool_start = std::time::Instant::now();
1194                        let dispatch = tools.invoke_with_audit(&name, args).await;
1195                        let result = match dispatch.result {
1196                            Ok(output) => output,
1197                            Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
1198                                DENIAL_LIMIT_SENTINEL.to_string()
1199                            }
1200                            Err(err) => format!("ERROR: {err}"),
1201                        };
1202                        let duration_ms = tool_start.elapsed().as_millis() as u64;
1203                        (id, name, result, dispatch.audit, duration_ms)
1204                    });
1205                }
1206
1207                type BatchRow = (String, String, String, crate::tools::AuditMeta, u64);
1208                // Use a HashMap keyed by tool_call_id so that O(1) lookup
1209                // preserves the correct audit metadata when multiple parallel
1210                // calls complete in arbitrary order (linear find would always
1211                // hit the first matching id, causing silent audit mis-attribution).
1212                let mut batch_map: std::collections::HashMap<String, BatchRow> =
1213                    std::collections::HashMap::with_capacity(batch.len());
1214                while let Some(res) = join_set.join_next().await {
1215                    match res {
1216                        Ok(row) => {
1217                            batch_map.insert(row.0.clone(), row);
1218                        }
1219                        Err(e) => {
1220                            tracing::error!(target: "recursive::agent", "parallel tool task panicked: {e}");
1221                        }
1222                    }
1223                }
1224
1225                for pc in &batch {
1226                    let Some((_, _, result, audit, duration_ms)) = batch_map.get(&pc.id) else {
1227                        // Task panicked — push a placeholder error result so
1228                        // the tool-call ↔ tool-result pairing invariant (#8)
1229                        // is preserved. Without this, the next LLM request
1230                        // would include an orphaned tool_call with no matching
1231                        // tool result and be rejected with HTTP 400.
1232                        results.push(ToolCallOutcome {
1233                            id: pc.id.clone(),
1234                            name: pc.name.clone(),
1235                            result: "ERROR: tool task panicked during parallel execution"
1236                                .to_string(),
1237                            audit: None,
1238                        });
1239                        continue;
1240                    };
1241                    results.push(ToolCallOutcome {
1242                        id: pc.id.clone(),
1243                        name: pc.name.clone(),
1244                        result: result.clone(),
1245                        audit: Some(audit.clone()),
1246                    });
1247                    self.hooks.dispatch(HookEvent::PostToolCall {
1248                        name: &pc.name,
1249                        args: &pc.args,
1250                        result,
1251                        duration_ms: *duration_ms,
1252                    });
1253                }
1254            } else {
1255                let pc = pending.remove(i);
1256                let tool_start = std::time::Instant::now();
1257                let dispatch = self
1258                    .tools
1259                    .invoke_with_audit(&pc.name, pc.args.clone())
1260                    .await;
1261                let result = match dispatch.result {
1262                    Ok(output) => output,
1263                    Err(crate::error::Error::PermissionDeniedLimit { .. }) => {
1264                        DENIAL_LIMIT_SENTINEL.to_string()
1265                    }
1266                    Err(err) => format!("ERROR: {err}"),
1267                };
1268                let duration_ms = tool_start.elapsed().as_millis() as u64;
1269                results.push(ToolCallOutcome {
1270                    id: pc.id.clone(),
1271                    name: pc.name.clone(),
1272                    result: result.clone(),
1273                    audit: Some(dispatch.audit),
1274                });
1275                self.hooks.dispatch(HookEvent::PostToolCall {
1276                    name: &pc.name,
1277                    args: &pc.args,
1278                    result: &result,
1279                    duration_ms,
1280                });
1281            }
1282        }
1283
1284        results
1285    }
1286
1287    /// Stateless core ReAct loop.  Consumes `self` and returns a
1288    /// [`RunInnerOutcome`] that the wrapper integrates into the parent agent.
1289    pub(crate) async fn run_inner(mut self) -> Result<RunInnerOutcome> {
1290        let specs = self.tools.specs();
1291
1292        let mut final_message: Option<String> = None;
1293        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1294            std::collections::VecDeque::with_capacity(self.stuck_window);
1295        let mut total_usage = TokenUsage::default();
1296        // Goal-318: one injector per run; tracks already-fired globs skills.
1297        let mut skill_injector = SkillInjector::new(&self.globs_skills);
1298        // Goal-153: audit metadata for tool calls, keyed by (turn, tool_call_id).
1299        let mut tool_audits: std::collections::HashMap<
1300            crate::tools::AuditKey,
1301            crate::tools::AuditMeta,
1302        > = std::collections::HashMap::new();
1303        self.total_llm_latency_ms = 0;
1304
1305        let step_cap = effective_step_limit(self.max_steps);
1306        for step in 1..=step_cap {
1307            let step_span = tracing::info_span!("agent.step", step);
1308            let _guard = step_span.enter();
1309
1310            // ---- shutdown cancellation -------------------------------------------
1311            // Termination check, parallel to the BudgetExceeded /
1312            // TranscriptLimit blocks below. If a CancellationToken was
1313            // configured (via AgentRuntimeBuilder::shutdown_token / etc.)
1314            // and it fired between steps, finish cleanly with
1315            // FinishReason::Cancelled.
1316            if let Some((finish, finished_steps)) = self.check_shutdown(step, &total_usage) {
1317                return Ok(self.make_outcome(
1318                    finish,
1319                    finished_steps,
1320                    final_message,
1321                    total_usage,
1322                    tool_audits,
1323                ));
1324            }
1325
1326            // ---- wall-clock deadline (Goal 345) ---------------------------------
1327            if let Some((finish, finished_steps)) = self.check_wall_deadline(step, &total_usage) {
1328                return Ok(self.make_outcome(
1329                    finish,
1330                    finished_steps,
1331                    final_message,
1332                    total_usage,
1333                    tool_audits,
1334                ));
1335            }
1336
1337            // ---- mailbox drain (coordinator → worker mid-run messages) -----------
1338            self.drain_mailbox().await;
1339
1340            // ---- transcript budget ------------------------------------------------
1341            if let Some((finish, finish_step)) = self.enforce_transcript_budget(step, &total_usage)
1342            {
1343                return Ok(self.make_outcome(
1344                    finish,
1345                    finish_step,
1346                    final_message,
1347                    total_usage,
1348                    tool_audits,
1349                ));
1350            }
1351
1352            // ---- compaction -------------------------------------------------------
1353            self.maybe_compact(step).await;
1354
1355            // ---- LLM call (with retry) --------------------------------------------
1356            let (completion, new_final_message) =
1357                match self.dispatch_llm_step(&specs, step, &mut total_usage).await {
1358                    Ok(v) => v,
1359                    Err(crate::error::Error::Cancelled) => {
1360                        // Mid-stream cancellation: the stream was interrupted partway
1361                        // through an LLM call. Route to FinishReason::Cancelled so the
1362                        // partial transcript is persisted by the caller. Invariant #7.
1363                        return Ok(self.make_cancelled_outcome(
1364                            step,
1365                            final_message,
1366                            total_usage,
1367                            tool_audits,
1368                        ));
1369                    }
1370                    Err(e) => return Err(e),
1371                };
1372            // Goal 382: a stream-interrupted completion (finish_reason
1373            // "interrupted") persists the partial reply as a Cancelled turn.
1374            if completion.finish_reason.as_deref() == Some("interrupted") {
1375                return Ok(self.finish_interrupted(completion, step, total_usage, tool_audits));
1376            }
1377            if let Some(text) = new_final_message {
1378                final_message = Some(text);
1379            }
1380
1381            // ---- no tool calls → finish -------------------------------------------
1382            if let Some(finish) = self.handle_no_tool_calls(&completion, step) {
1383                return Ok(self.make_outcome(
1384                    finish,
1385                    step,
1386                    final_message,
1387                    total_usage,
1388                    tool_audits,
1389                ));
1390            }
1391
1392            self.push_message(Message::assistant_with_tool_calls(
1393                completion.content.clone(),
1394                completion.tool_calls.clone(),
1395            ));
1396            self.attach_reasoning_content(completion.reasoning_content.clone());
1397
1398            for call in &completion.tool_calls {
1399                self.emit(AgentEvent::ToolCall {
1400                    name: call.name.clone(),
1401                    id: call.id.clone(),
1402                    arguments: call.arguments.to_string(),
1403                    step,
1404                });
1405            }
1406
1407            // ---- tool execution ---------------------------------------------------
1408            let results = self.execute_tool_calls(&completion.tool_calls).await;
1409
1410            if let Some(finish) = self.process_tool_results(
1411                &results,
1412                step,
1413                &mut recent_errors,
1414                &mut tool_audits,
1415                &mut skill_injector,
1416            ) {
1417                return Ok(self.make_outcome(
1418                    finish,
1419                    step,
1420                    final_message,
1421                    total_usage,
1422                    tool_audits,
1423                ));
1424            }
1425        }
1426
1427        warn!(target: "recursive::agent", "step budget exceeded");
1428        let finish = FinishReason::BudgetExceeded;
1429        let steps = self.max_steps;
1430        self.emit(AgentEvent::TurnFinished {
1431            reason: finish_reason_str(&finish),
1432            steps,
1433        });
1434        Ok(self.make_outcome(finish, steps, final_message, total_usage, tool_audits))
1435    }
1436}
1437
1438/// Step cap for the agent loop. `0` means unlimited (no `BudgetExceeded`).
1439///
1440/// If the `RECURSIVE_HARD_STEP_CAP` env var is set to a positive integer,
1441/// the cap is clamped to that value regardless of `max_steps`. This lets
1442/// operators enforce a production ceiling on `recursive loop` long-running
1443/// sessions (which default to `max_steps=0` / unbounded) without changing
1444/// the per-session contract. When unset or zero, behaviour is unchanged:
1445/// `max_steps=0` still means `usize::MAX`.
1446fn effective_step_limit(max_steps: usize) -> usize {
1447    let requested = if max_steps == 0 {
1448        usize::MAX
1449    } else {
1450        max_steps
1451    };
1452    match hard_step_cap_from_env() {
1453        Some(cap) if cap > 0 => requested.min(cap),
1454        _ => requested,
1455    }
1456}
1457
1458/// Read the `RECURSIVE_HARD_STEP_CAP` env var once per call. Returns
1459/// `None` when unset or unparseable. A value of `0` is treated as
1460/// "unset" (matches the `max_steps=0` unlimited convention).
1461fn hard_step_cap_from_env() -> Option<usize> {
1462    std::env::var("RECURSIVE_HARD_STEP_CAP")
1463        .ok()
1464        .and_then(|s| s.parse::<usize>().ok())
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469    use std::sync::Arc;
1470
1471    use super::{
1472        effective_step_limit, estimate_prompt_tokens, estimate_tokens_by_bytes, finish_reason_str,
1473        RunCore, StaticBreakdownCache, MIN_TRIM_LENGTH, TRIM_PLACEHOLDER,
1474    };
1475    use crate::message::Message;
1476
1477    // The three `effective_step_limit_*` tests mutate the process-global
1478    // `RECURSIVE_HARD_STEP_CAP` env var. Running in parallel they race each
1479    // other (one test's set_var lands while another is mid-assert), so the
1480    // env access must be serialised. This lock is held only by those tests.
1481    static HARD_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1482
1483    #[test]
1484    fn effective_step_limit_zero_means_unbounded() {
1485        let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1486        // Env var must be unset for this test — its presence would clamp.
1487        let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1488        std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1489        assert_eq!(effective_step_limit(0), usize::MAX);
1490        assert_eq!(effective_step_limit(32), 32);
1491        if let Some(v) = orig {
1492            std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1493        }
1494    }
1495
1496    #[test]
1497    fn effective_step_limit_respects_hard_cap_when_set() {
1498        let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1499        let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1500        std::env::set_var("RECURSIVE_HARD_STEP_CAP", "1000");
1501
1502        // Unlimited path is clamped to the cap.
1503        assert_eq!(effective_step_limit(0), 1000);
1504        // Limited path below the cap stays as-is.
1505        assert_eq!(effective_step_limit(32), 32);
1506        // Limited path above the cap is clamped.
1507        assert_eq!(effective_step_limit(5000), 1000);
1508
1509        if let Some(v) = orig {
1510            std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1511        } else {
1512            std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1513        }
1514    }
1515
1516    #[test]
1517    fn effective_step_limit_ignores_invalid_hard_cap() {
1518        let _guard = HARD_CAP_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1519        let orig = std::env::var("RECURSIVE_HARD_STEP_CAP").ok();
1520        std::env::set_var("RECURSIVE_HARD_STEP_CAP", "not-a-number");
1521        assert_eq!(effective_step_limit(0), usize::MAX);
1522        std::env::set_var("RECURSIVE_HARD_STEP_CAP", "0");
1523        // Cap value of 0 is treated as "unset" → unlimited preserved.
1524        assert_eq!(effective_step_limit(0), usize::MAX);
1525        if let Some(v) = orig {
1526            std::env::set_var("RECURSIVE_HARD_STEP_CAP", v);
1527        } else {
1528            std::env::remove_var("RECURSIVE_HARD_STEP_CAP");
1529        }
1530    }
1531
1532    /// Verify the stuck-detection window/rate math that RunCore uses.
1533    /// This tests the same logic as the sliding-window check in run_inner().
1534    #[test]
1535    fn stuck_detection_window_and_rate() {
1536        // Simulate: stuck_window=3, stuck_error_rate=1.0 → 3 consecutive errors triggers stuck
1537        let stuck_window = 3usize;
1538        let stuck_error_rate = 1.0f64;
1539
1540        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1541            std::collections::VecDeque::with_capacity(stuck_window);
1542
1543        // Push 2 errors — should not trigger yet
1544        for i in 0..2 {
1545            if recent_errors.len() == stuck_window {
1546                recent_errors.pop_front();
1547            }
1548            let name = format!("tool_{i}");
1549            recent_errors.push_back((true, name));
1550            let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64
1551                / recent_errors.len() as f64;
1552            assert!(
1553                recent_errors.len() < stuck_window || rate < stuck_error_rate,
1554                "should not trigger before window is full"
1555            );
1556        }
1557
1558        // Push 3rd error — window is now full and rate = 1.0 ≥ threshold
1559        if recent_errors.len() == stuck_window {
1560            recent_errors.pop_front();
1561        }
1562        recent_errors.push_back((true, "tool_2".to_string()));
1563        assert_eq!(recent_errors.len(), stuck_window);
1564        let error_count = recent_errors.iter().filter(|(e, _)| *e).count();
1565        let rate = error_count as f64 / stuck_window as f64;
1566        assert!(
1567            rate >= stuck_error_rate,
1568            "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
1569        );
1570
1571        // Verify most-repeated tool reporting logic
1572        let error_entries: Vec<&str> = recent_errors
1573            .iter()
1574            .filter(|(e, _)| *e)
1575            .map(|(_, n)| n.as_str())
1576            .collect();
1577        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1578        for n in &error_entries {
1579            *counts.entry(n).or_default() += 1;
1580        }
1581        let top_tool = counts.into_iter().max_by_key(|&(_, c)| c).map(|(n, _)| n);
1582        // All three are distinct so any one could be "top"; just verify it exists
1583        assert!(top_tool.is_some());
1584    }
1585
1586    #[test]
1587    fn stuck_detection_partial_errors_below_threshold() {
1588        // window=4, rate=0.8 → need 4 errors out of 4 (or round up).
1589        // With 3 errors + 1 success the rate is 0.75 < 0.8, so no trigger.
1590        let stuck_window = 4usize;
1591        let stuck_error_rate = 0.8f64;
1592
1593        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1594            std::collections::VecDeque::with_capacity(stuck_window);
1595
1596        let pattern = [
1597            (true, "tool_a"),
1598            (true, "tool_b"),
1599            (true, "tool_a"),
1600            (false, "tool_c"),
1601        ]; // 3/4 errors = 0.75
1602        for (is_error, name) in &pattern {
1603            if recent_errors.len() == stuck_window {
1604                recent_errors.pop_front();
1605            }
1606            recent_errors.push_back((*is_error, name.to_string()));
1607        }
1608
1609        assert_eq!(recent_errors.len(), stuck_window);
1610        let rate = recent_errors.iter().filter(|(e, _)| *e).count() as f64 / stuck_window as f64;
1611        assert!(
1612            rate < stuck_error_rate,
1613            "rate {rate} should be below threshold {stuck_error_rate}"
1614        );
1615    }
1616
1617    #[test]
1618    fn stuck_detection_reports_most_repeated_tool() {
1619        // window=4, rate=0.75, pattern: tool_a err, tool_b err, tool_a err, tool_b ok
1620        // error_count=3, rate=0.75, most repeated should be tool_a (appears 2× in errors)
1621        let stuck_window = 4usize;
1622        let stuck_error_rate = 0.75f64;
1623
1624        let mut recent_errors: std::collections::VecDeque<(bool, String)> =
1625            std::collections::VecDeque::with_capacity(stuck_window);
1626
1627        let pattern = [
1628            (true, "tool_a"),
1629            (true, "tool_b"),
1630            (true, "tool_a"),
1631            (false, "tool_b"),
1632        ];
1633        for (is_error, name) in &pattern {
1634            if recent_errors.len() == stuck_window {
1635                recent_errors.pop_front();
1636            }
1637            recent_errors.push_back((*is_error, name.to_string()));
1638        }
1639
1640        assert_eq!(recent_errors.len(), stuck_window);
1641
1642        let error_entries: Vec<&str> = recent_errors
1643            .iter()
1644            .filter(|(is_err, _)| *is_err)
1645            .map(|(_, n)| n.as_str())
1646            .collect();
1647        let error_count = error_entries.len();
1648        let rate = error_count as f64 / stuck_window as f64;
1649        assert!(
1650            rate >= stuck_error_rate,
1651            "rate {rate} should trigger stuck at threshold {stuck_error_rate}"
1652        );
1653
1654        // Find the most frequently appearing tool name in the error window.
1655        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1656        for n in &error_entries {
1657            *counts.entry(n).or_default() += 1;
1658        }
1659        let top_tool = counts
1660            .into_iter()
1661            .max_by_key(|&(_, c)| c)
1662            .map(|(n, _)| n)
1663            .unwrap();
1664        assert_eq!(
1665            top_tool, "tool_a",
1666            "most repeated tool should be tool_a (2 errors), not tool_b (1 error)"
1667        );
1668    }
1669
1670    // Helper: build a minimal RunCore suitable for testing methods that only
1671    // touch `messages` (e.g. `attach_reasoning_content`).
1672    fn make_test_core<'a>(
1673        messages: Vec<Message>,
1674        hooks: &'a crate::hooks::HookRegistry,
1675    ) -> RunCore<'a> {
1676        use std::sync::atomic::AtomicBool;
1677        use std::sync::Arc;
1678        RunCore {
1679            messages: Arc::new(messages),
1680            llm: Arc::new(crate::llm::MockProvider::new(vec![])),
1681            tools: Arc::new(crate::tools::ToolRegistry::default()),
1682            max_steps: 1,
1683            max_transcript_chars: None,
1684            events: None,
1685            streaming: false,
1686            compactor: None,
1687            microcompactor: None,
1688            permission_hook: None,
1689            hooks,
1690            total_llm_latency_ms: 0,
1691            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
1692            shutdown_token: None,
1693            mailbox: None,
1694            stuck_window: 3,
1695            stuck_error_rate: 1.0,
1696            turn: 0,
1697            globs_skills: vec![],
1698            prompt_segments: None,
1699            static_breakdown: StaticBreakdownCache::default(),
1700            last_prompt_tokens: 0,
1701            consecutive_compact_failures: 0,
1702            wall_timeout_secs: 0,
1703            wall_start: None,
1704        }
1705    }
1706
1707    #[test]
1708    fn attach_reasoning_content_sets_last_message_when_some() {
1709        let hooks = crate::hooks::HookRegistry::new();
1710        let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);
1711
1712        core.attach_reasoning_content(Some("I thought carefully about this.".to_string()));
1713
1714        assert_eq!(
1715            core.messages.last().unwrap().reasoning_content.as_deref(),
1716            Some("I thought carefully about this."),
1717            "reasoning_content should be set on the last message"
1718        );
1719    }
1720
1721    #[test]
1722    fn attach_reasoning_content_does_not_modify_when_none() {
1723        let hooks = crate::hooks::HookRegistry::new();
1724        let mut core = make_test_core(vec![Message::assistant("response".to_string())], &hooks);
1725
1726        core.attach_reasoning_content(None);
1727
1728        assert!(
1729            core.messages.last().unwrap().reasoning_content.is_none(),
1730            "reasoning_content should remain None when called with None"
1731        );
1732    }
1733
1734    #[test]
1735    fn attach_reasoning_content_preserves_existing_content_when_none() {
1736        let hooks = crate::hooks::HookRegistry::new();
1737        let mut msg = Message::assistant("response".to_string());
1738        msg.reasoning_content = Some("prior thinking".to_string());
1739        let mut core = make_test_core(vec![msg], &hooks);
1740
1741        core.attach_reasoning_content(None);
1742
1743        assert_eq!(
1744            core.messages.last().unwrap().reasoning_content.as_deref(),
1745            Some("prior thinking"),
1746            "calling with None should not overwrite existing reasoning_content"
1747        );
1748    }
1749
1750    // ========================================================================
1751    // maybe_trim_transcript tests
1752    // ========================================================================
1753
1754    // ========================================================================
1755    // finish_reason_str tests
1756    // (kills String::new() and "xyzzy" function-level mutants)
1757    // ========================================================================
1758
1759    #[test]
1760    fn finish_reason_str_is_nonempty_and_not_xyzzy() {
1761        use crate::agent::FinishReason;
1762        let reason = FinishReason::NoMoreToolCalls;
1763        let s = finish_reason_str(&reason);
1764        assert!(
1765            !s.is_empty(),
1766            "finish_reason_str must not return empty string"
1767        );
1768        assert_ne!(s, "xyzzy", "finish_reason_str must not return 'xyzzy'");
1769    }
1770
1771    #[test]
1772    fn finish_reason_str_matches_display() {
1773        use crate::agent::FinishReason;
1774        let reason = FinishReason::BudgetExceeded;
1775        let s = finish_reason_str(&reason);
1776        assert_eq!(
1777            s,
1778            reason.to_string(),
1779            "finish_reason_str must delegate to Display"
1780        );
1781    }
1782
1783    // ========================================================================
1784    // estimate_prompt_tokens / fallback-usage tests
1785    // (cover the else branch in dispatch_llm_step used when the provider
1786    //  does not report `usage`)
1787    // ========================================================================
1788
1789    #[test]
1790    fn estimate_prompt_tokens_divides_total_chars_by_four() {
1791        // 3 messages: 4 + 8 + 12 = 24 chars → 24 / 4 = 6 tokens.
1792        let messages = vec![
1793            Message::user("abcd".to_string()),           // 4
1794            Message::assistant("abcdefgh".to_string()),  // 8
1795            Message::system("abcdefghijkl".to_string()), // 12
1796        ];
1797        assert_eq!(
1798            estimate_prompt_tokens(&messages),
1799            6,
1800            "24 total chars / 4 = 6 tokens"
1801        );
1802    }
1803
1804    #[test]
1805    fn estimate_prompt_tokens_truncates_partial_tokens() {
1806        // 7 chars → 7 / 4 = 1 (integer truncation), not rounded up to 2.
1807        let messages = vec![Message::user("abcdefg".to_string())];
1808        assert_eq!(
1809            estimate_prompt_tokens(&messages),
1810            1,
1811            "integer division must truncate 7/4 to 1"
1812        );
1813    }
1814
1815    #[test]
1816    fn estimate_prompt_tokens_floors_to_one_for_empty_messages() {
1817        // No messages → 0 / 4 = 0, but .max(1) floors to 1 so the gauge
1818        // never reports zero (the system prompt always exists).
1819        let messages: Vec<Message> = vec![];
1820        assert_eq!(
1821            estimate_prompt_tokens(&messages),
1822            1,
1823            "empty transcript must still report at least 1 token"
1824        );
1825    }
1826
1827    #[test]
1828    fn estimate_prompt_tokens_floors_to_one_for_sub_four_chars() {
1829        // 3 chars → 3 / 4 = 0, floored to 1 by .max(1).
1830        let messages = vec![Message::user("abc".to_string())];
1831        assert_eq!(
1832            estimate_prompt_tokens(&messages),
1833            1,
1834            "content shorter than 4 chars must floor to 1 token"
1835        );
1836    }
1837
1838    // Helper: build a RunCore wired to a custom provider and event sender so
1839    // tests can drive `dispatch_llm_step` and inspect emitted events.
1840    fn make_core_with_llm_and_events<'a>(
1841        messages: Vec<Message>,
1842        llm: Arc<dyn crate::llm::ChatProvider>,
1843        events: tokio::sync::mpsc::UnboundedSender<crate::event::AgentEvent>,
1844        hooks: &'a crate::hooks::HookRegistry,
1845    ) -> RunCore<'a> {
1846        use std::sync::atomic::AtomicBool;
1847        RunCore {
1848            messages: Arc::new(messages),
1849            llm,
1850            tools: Arc::new(crate::tools::ToolRegistry::default()),
1851            max_steps: 1,
1852            max_transcript_chars: None,
1853            events: Some(events),
1854            streaming: false,
1855            compactor: None,
1856            microcompactor: None,
1857            permission_hook: None,
1858            hooks,
1859            total_llm_latency_ms: 0,
1860            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
1861            shutdown_token: None,
1862            mailbox: None,
1863            stuck_window: 3,
1864            stuck_error_rate: 1.0,
1865            turn: 0,
1866            globs_skills: vec![],
1867            prompt_segments: None,
1868            static_breakdown: StaticBreakdownCache::default(),
1869            last_prompt_tokens: 0,
1870            consecutive_compact_failures: 0,
1871            wall_timeout_secs: 0,
1872            wall_start: None,
1873        }
1874    }
1875
1876    /// When the provider returns a completion without `usage`, the fallback
1877    /// branch must emit a best-effort estimated `Usage` event: input from the
1878    /// transcript chars, output from `content` + `reasoning_content`, both
1879    /// divided by 4, with zero cache tokens — and it must NOT accumulate into
1880    /// `total_usage` (only provider-reported usage does).
1881    #[tokio::test]
1882    async fn dispatch_llm_step_emits_estimated_usage_when_provider_reports_none() {
1883        use crate::event::AgentEvent;
1884        use crate::llm::{Completion, MockProvider, TokenUsage};
1885
1886        let hooks = crate::hooks::HookRegistry::new();
1887        // Transcript: a single user message of exactly 40 bytes → 40/4 = 10.
1888        let messages = vec![Message::user("x".repeat(40))];
1889
1890        // Completion WITHOUT usage forces the fallback branch.
1891        // content = 8 bytes, reasoning = 4 bytes → 12/4 = 3 output tokens.
1892        let completion = Completion {
1893            content: "12345678".to_string(),
1894            tool_calls: vec![],
1895            finish_reason: Some("stop".to_string()),
1896            usage: None,
1897            reasoning_content: Some("abcd".to_string()),
1898        };
1899        let llm = Arc::new(MockProvider::new(vec![completion]));
1900
1901        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AgentEvent>();
1902        let mut core = make_core_with_llm_and_events(messages, llm, tx, &hooks);
1903
1904        let specs: Vec<crate::llm::ToolSpec> = vec![];
1905        let mut total_usage = TokenUsage::default();
1906        core.dispatch_llm_step(&specs, 0, &mut total_usage)
1907            .await
1908            .expect("dispatch_llm_step should succeed");
1909
1910        // Estimated usage must not be folded into total_usage.
1911        assert_eq!(
1912            total_usage.prompt_tokens, 0,
1913            "estimate must not accumulate input into total_usage"
1914        );
1915        assert_eq!(
1916            total_usage.completion_tokens, 0,
1917            "estimate must not accumulate output into total_usage"
1918        );
1919
1920        // Locate the emitted Usage event.
1921        let mut usage_event = None;
1922        while let Ok(ev) = rx.try_recv() {
1923            if matches!(ev, AgentEvent::Usage { .. }) {
1924                usage_event = Some(ev);
1925                break;
1926            }
1927        }
1928        match usage_event.expect("fallback branch must emit a Usage event") {
1929            AgentEvent::Usage {
1930                input_tokens,
1931                output_tokens,
1932                cache_hit_tokens,
1933                cache_miss_tokens,
1934                step,
1935            } => {
1936                assert_eq!(input_tokens, 10, "input estimate = 40 chars / 4");
1937                assert_eq!(output_tokens, 3, "output estimate = (8 + 4) chars / 4");
1938                assert_eq!(cache_hit_tokens, 0, "estimate reports no cache hits");
1939                assert_eq!(cache_miss_tokens, 0, "estimate reports no cache misses");
1940                assert_eq!(step, 0, "step must be forwarded unchanged");
1941            }
1942            other => panic!("expected Usage event, got {other:?}"),
1943        }
1944    }
1945
1946    // ========================================================================
1947    // maybe_trim_transcript tests
1948    // ========================================================================
1949
1950    #[test]
1951    fn maybe_trim_does_nothing_when_under_limit() {
1952        let hooks = crate::hooks::HookRegistry::new();
1953        // Use a large tool result (> MIN_TRIM_LENGTH) but keep limit ABOVE total bytes.
1954        // This kills the `replace < with ==` mutant: with `==`, bytes != limit means
1955        // the early-return is skipped and the tool result would be incorrectly trimmed.
1956        let large_output = "x".repeat(MIN_TRIM_LENGTH + 10);
1957        let messages = vec![
1958            Message::system("sys".to_string()),
1959            Message::tool_result("c1", &large_output),
1960        ];
1961        let orig_content = messages[1].content.clone();
1962        let total_bytes = "sys".len() + large_output.len();
1963        let mut core = make_test_core(messages, &hooks);
1964
1965        // Set limit strictly ABOVE total bytes (not equal), so trimming must not fire.
1966        let limit = total_bytes + 1;
1967        core.maybe_trim_transcript(limit, 0);
1968
1969        assert_eq!(
1970            core.messages[1].content, orig_content,
1971            "content must not be touched when total bytes < limit"
1972        );
1973    }
1974
1975    #[test]
1976    fn enforce_transcript_budget_returns_none_when_under_limit() {
1977        // kills `replace < with == in enforce_transcript_budget` mutation.
1978        // If changed to `==`, a core with chars clearly under the limit would
1979        // return Some (falsely claiming budget exceeded) instead of None.
1980        use crate::llm::TokenUsage;
1981        let hooks = crate::hooks::HookRegistry::new();
1982        // One short message: "hi" = 2 chars
1983        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
1984        // Set limit to 1000 — far above 2 chars
1985        core.max_transcript_chars = Some(1000);
1986        let usage = TokenUsage::default();
1987        let result = core.enforce_transcript_budget(0, &usage);
1988        assert!(
1989            result.is_none(),
1990            "chars (2) < limit (1000) must return None; got Some(_)"
1991        );
1992    }
1993
1994    #[test]
1995    fn enforce_transcript_budget_returns_finish_reason_when_over_limit() {
1996        // Validates the >-limit path: when chars >= limit, must return Some with TranscriptLimit.
1997        use crate::agent::FinishReason;
1998        use crate::llm::TokenUsage;
1999        let hooks = crate::hooks::HookRegistry::new();
2000        // Message with 100 'x' chars — well above a limit of 10
2001        let msg = Message::user("x".repeat(100));
2002        let mut core = make_test_core(vec![msg], &hooks);
2003        core.max_transcript_chars = Some(10);
2004        let usage = TokenUsage::default();
2005        let result = core.enforce_transcript_budget(0, &usage);
2006        assert!(
2007            result.is_some(),
2008            "chars (100) >= limit (10) must return Some finish reason"
2009        );
2010        let (finish, _step) = result.unwrap();
2011        assert!(
2012            matches!(finish, FinishReason::TranscriptLimit { .. }),
2013            "finish reason must be TranscriptLimit; got {finish:?}"
2014        );
2015    }
2016
2017    #[test]
2018    fn maybe_trim_replaces_large_tool_result_when_over_limit() {
2019        let hooks = crate::hooks::HookRegistry::new();
2020        // A tool result with content > MIN_TRIM_LENGTH (200) bytes
2021        let large_output = "x".repeat(MIN_TRIM_LENGTH + 100);
2022        let messages = vec![
2023            Message::system("sys".to_string()),
2024            Message::tool_result("c1", &large_output),
2025        ];
2026        let mut core = make_test_core(messages, &hooks);
2027
2028        // Set limit below total content so trimming fires
2029        core.maybe_trim_transcript(10, 0);
2030
2031        assert_eq!(
2032            core.messages[1].content, TRIM_PLACEHOLDER,
2033            "large tool result must be replaced with placeholder"
2034        );
2035    }
2036
2037    #[test]
2038    fn maybe_trim_does_not_trim_small_tool_results() {
2039        let hooks = crate::hooks::HookRegistry::new();
2040        // A tool result shorter than MIN_TRIM_LENGTH
2041        let small_output = "small".to_string();
2042        let messages = vec![
2043            Message::system("sys".to_string()),
2044            Message::tool_result("c1", &small_output),
2045        ];
2046        let mut core = make_test_core(messages, &hooks);
2047
2048        // Trigger trim (limit = 0)
2049        core.maybe_trim_transcript(0, 0);
2050
2051        assert_eq!(
2052            core.messages[1].content, small_output,
2053            "short tool result must not be trimmed (< MIN_TRIM_LENGTH)"
2054        );
2055    }
2056
2057    #[test]
2058    fn maybe_trim_does_not_trim_exactly_min_trim_length() {
2059        // Kills the `replace > with >=` mutant: content.len() == MIN_TRIM_LENGTH
2060        // must NOT be trimmed under the strict `>` check.
2061        let hooks = crate::hooks::HookRegistry::new();
2062        let exact_output = "z".repeat(MIN_TRIM_LENGTH);
2063        let messages = vec![
2064            Message::system("s".to_string()),
2065            Message::tool_result("c1", &exact_output),
2066        ];
2067        let mut core = make_test_core(messages, &hooks);
2068        // Set limit = 0 so trimming is attempted
2069        core.maybe_trim_transcript(0, 0);
2070        assert_eq!(
2071            core.messages[1].content, exact_output,
2072            "content exactly at MIN_TRIM_LENGTH must NOT be trimmed (> is strict)"
2073        );
2074    }
2075
2076    #[test]
2077    fn maybe_trim_trims_one_more_than_min_trim_length() {
2078        // Complementary to the above: content.len() == MIN_TRIM_LENGTH + 1 MUST be trimmed.
2079        let hooks = crate::hooks::HookRegistry::new();
2080        let just_over = "z".repeat(MIN_TRIM_LENGTH + 1);
2081        let messages = vec![
2082            Message::system("s".to_string()),
2083            Message::tool_result("c1", &just_over),
2084        ];
2085        let mut core = make_test_core(messages, &hooks);
2086        core.maybe_trim_transcript(0, 0);
2087        assert_eq!(
2088            core.messages[1].content, TRIM_PLACEHOLDER,
2089            "content one byte over MIN_TRIM_LENGTH must be trimmed"
2090        );
2091    }
2092
2093    #[test]
2094    fn maybe_trim_stops_after_fitting_under_limit() {
2095        let hooks = crate::hooks::HookRegistry::new();
2096        // Two large tool results; only trim until we go under the limit.
2097        let large1 = "a".repeat(MIN_TRIM_LENGTH + 100);
2098        let large2 = "b".repeat(MIN_TRIM_LENGTH + 100);
2099        let messages = vec![
2100            Message::system("sys".to_string()),
2101            Message::tool_result("c1", &large1),
2102            Message::tool_result("c2", &large2),
2103        ];
2104        let mut core = make_test_core(messages, &hooks);
2105
2106        // Set limit just above placeholder_len so trimming the first result suffices.
2107        let placeholder_len = TRIM_PLACEHOLDER.len();
2108        let limit = 3 + placeholder_len + large2.len() + 50; // sys + placeholder + large2 + buffer
2109        core.maybe_trim_transcript(limit, 0);
2110
2111        assert_eq!(
2112            core.messages[1].content, TRIM_PLACEHOLDER,
2113            "first large result should be trimmed"
2114        );
2115        assert_eq!(
2116            core.messages[2].content, large2,
2117            "second result should not be touched once we're under limit"
2118        );
2119    }
2120
2121    #[test]
2122    fn maybe_trim_trims_when_chars_exactly_equals_limit() {
2123        // Kills: `replace < with <=` at line 196.
2124        //
2125        // When chars == limit the original `<` guard is FALSE → trimming proceeds.
2126        // With `<=`, it would be TRUE → early return, nothing is trimmed.
2127        let hooks = crate::hooks::HookRegistry::new();
2128        // Build a transcript whose total content length == limit exactly.
2129        let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); // 250 chars
2130        let messages = vec![
2131            Message::system("s".to_string()),          // 1 char
2132            Message::tool_result("c1", &tool_content), // 250 chars
2133        ];
2134        // Total = 1 + 250 = 251.  Set limit = 251 so chars == limit.
2135        let limit = 1 + tool_content.len();
2136        let mut core = make_test_core(messages, &hooks);
2137        core.maybe_trim_transcript(limit, 0);
2138
2139        assert_eq!(
2140            core.messages[1].content, TRIM_PLACEHOLDER,
2141            "tool result must be trimmed when chars == limit (not strictly less)"
2142        );
2143    }
2144
2145    #[test]
2146    fn maybe_trim_continues_when_chars_equals_limit_after_first_trim() {
2147        // Kills: `replace < with <=` at line 211 (the inner break condition).
2148        //
2149        // After trimming the first tool result, if the remaining chars == limit,
2150        // the original `<` is FALSE → the loop continues and trims the second tool result.
2151        // With `<=`, it would be TRUE → break, leaving the second tool result untrimmed.
2152        let hooks = crate::hooks::HookRegistry::new();
2153
2154        let tool_content = "a".repeat(MIN_TRIM_LENGTH + 50); // 250 chars
2155        let messages = vec![
2156            Message::system("s".to_string()),          // 1 char
2157            Message::tool_result("c1", &tool_content), // 250 chars (will be trimmed first)
2158            Message::tool_result("c2", &tool_content), // 250 chars (must ALSO be trimmed)
2159        ];
2160        // Total = 1 + 250 + 250 = 501
2161        // TRIM_PLACEHOLDER.len() = 40
2162        // After trimming tool1: 501 - 250 + 40 = 291
2163        // Set limit = 291 so that after trimming tool1, chars == limit (not < limit).
2164        // Original `<`: 291 < 291 → false → continues trimming tool2.
2165        // Mutant  `<=`: 291 <= 291 → true  → breaks, tool2 NOT trimmed.
2166        let after_first_trim = 1 + TRIM_PLACEHOLDER.len() + tool_content.len();
2167        let limit = after_first_trim; // exact boundary
2168
2169        let mut core = make_test_core(messages, &hooks);
2170        core.maybe_trim_transcript(limit, 0);
2171
2172        assert_eq!(
2173            core.messages[1].content, TRIM_PLACEHOLDER,
2174            "first tool result must be trimmed"
2175        );
2176        assert_eq!(
2177            core.messages[2].content, TRIM_PLACEHOLDER,
2178            "second tool result must ALSO be trimmed when chars == limit after first trim"
2179        );
2180    }
2181
2182    #[test]
2183    fn maybe_trim_emits_event_when_trimmed() {
2184        // Kills: `replace += with *=` (trimmed_count stays 0 → event not emitted)
2185        //        `replace > with ==/</>= in if trimmed_count > 0` (wrong guard)
2186        use crate::event::AgentEvent;
2187        use tokio::sync::mpsc;
2188
2189        let hooks = crate::hooks::HookRegistry::new();
2190        let large_output = "x".repeat(MIN_TRIM_LENGTH + 50);
2191        let messages = vec![
2192            Message::system("sys".to_string()),
2193            Message::tool_result("c1", &large_output),
2194        ];
2195        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2196        let mut core = make_test_core(messages, &hooks);
2197        core.events = Some(tx);
2198
2199        // Trim fires (limit = 0 → chars > 0 = limit)
2200        core.maybe_trim_transcript(0, 0);
2201
2202        // The emit must have sent an AssistantText event containing the trim notice
2203        let event = rx
2204            .try_recv()
2205            .expect("an event must have been emitted after trimming");
2206        match event {
2207            AgentEvent::AssistantText { text, .. } => {
2208                assert!(
2209                    text.contains("trimmed"),
2210                    "trim event text must mention 'trimmed'; got: {text}"
2211                );
2212            }
2213            other => panic!("expected AssistantText but got: {other:?}"),
2214        }
2215    }
2216
2217    #[test]
2218    fn maybe_trim_no_event_when_nothing_trimmed() {
2219        // Complementary: no event when nothing is trimmed (trimmed_count == 0)
2220        use crate::event::AgentEvent;
2221        use tokio::sync::mpsc;
2222
2223        let hooks = crate::hooks::HookRegistry::new();
2224        let small_output = "tiny".to_string(); // Below MIN_TRIM_LENGTH
2225        let messages = vec![
2226            Message::system("sys".to_string()),
2227            Message::tool_result("c1", &small_output),
2228        ];
2229        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2230        let mut core = make_test_core(messages, &hooks);
2231        core.events = Some(tx);
2232
2233        // Limit = 0 → trimming is attempted, but nothing qualifies (< MIN_TRIM_LENGTH)
2234        core.maybe_trim_transcript(0, 0);
2235
2236        assert!(
2237            rx.try_recv().is_err(),
2238            "no event must be emitted when nothing was trimmed"
2239        );
2240    }
2241
2242    // ========================================================================
2243    // maybe_compact tests
2244    // ========================================================================
2245
2246    #[tokio::test]
2247    async fn maybe_compact_noop_when_no_compactor() {
2248        let hooks = crate::hooks::HookRegistry::new();
2249        let messages = vec![
2250            Message::user("q".to_string()),
2251            Message::assistant("a".to_string()),
2252        ];
2253        let mut core = make_test_core(messages, &hooks);
2254        // compactor is None by default in make_test_core
2255        core.maybe_compact(0).await;
2256        assert_eq!(core.messages.len(), 2, "messages must not change");
2257    }
2258
2259    #[tokio::test]
2260    async fn maybe_compact_noop_when_under_threshold() {
2261        use crate::compact::Compactor;
2262        let hooks = crate::hooks::HookRegistry::new();
2263        let messages = vec![
2264            Message::user("hi".to_string()),
2265            Message::assistant("hello".to_string()),
2266        ];
2267        let mut core = make_test_core(messages, &hooks);
2268        // Set a threshold much larger than the transcript
2269        core.compactor = Some(Compactor::new(usize::MAX));
2270
2271        core.maybe_compact(0).await;
2272        assert_eq!(core.messages.len(), 2, "no compaction under threshold");
2273    }
2274
2275    #[tokio::test]
2276    async fn maybe_compact_fires_when_over_threshold() {
2277        use crate::compact::Compactor;
2278        use crate::llm::{Completion, MockProvider};
2279        let hooks = crate::hooks::HookRegistry::new();
2280        // Build a transcript that exceeds the threshold
2281        let messages = vec![
2282            Message::system("sys".to_string()),
2283            Message::user("msg1".to_string()),
2284            Message::assistant("rep1".to_string()),
2285            Message::user("msg2".to_string()),
2286            Message::assistant("rep2".to_string()),
2287            Message::user("msg3".to_string()),
2288        ];
2289        let provider = Arc::new(MockProvider::new(vec![Completion {
2290            content: "compact summary".to_string(),
2291            tool_calls: vec![],
2292            finish_reason: Some("stop".to_string()),
2293            usage: None,
2294            reasoning_content: None,
2295        }]));
2296        let mut core = make_test_core(messages, &hooks);
2297        core.llm = provider;
2298        // Threshold = 0 → always compact
2299        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2300
2301        core.maybe_compact(1).await;
2302        // After compaction the transcript starts with a compaction summary
2303        assert!(
2304            core.messages[0].is_compaction_summary,
2305            "first message must be a compaction summary after compaction"
2306        );
2307    }
2308
2309    #[tokio::test]
2310    async fn maybe_compact_fires_at_threshold_boundary() {
2311        // Kills: `replace < with <=` mutant at line 239.
2312        // With `<=`, when chars == threshold_chars, the function returns early instead of compacting.
2313        use crate::compact::Compactor;
2314        use crate::llm::{Completion, MockProvider};
2315        let hooks = crate::hooks::HookRegistry::new();
2316        let messages = vec![
2317            Message::system("sys".to_string()),
2318            Message::user("msg1".to_string()),
2319            Message::assistant("rep1".to_string()),
2320            Message::user("msg2".to_string()),
2321            Message::assistant("rep2".to_string()),
2322            Message::user("last".to_string()),
2323        ];
2324        // Compute exact bytes
2325        let bytes = crate::compact::Compactor::estimate_bytes(&messages);
2326        let provider = Arc::new(MockProvider::new(vec![Completion {
2327            content: "summary".to_string(),
2328            tool_calls: vec![],
2329            finish_reason: Some("stop".to_string()),
2330            usage: None,
2331            reasoning_content: None,
2332        }]));
2333        let mut core = make_test_core(messages, &hooks);
2334        core.llm = provider;
2335        // Set threshold = exact bytes → `bytes < threshold` is false → compaction fires
2336        // With `< → <=`: `bytes <= bytes` is true → returns early → no compaction
2337        core.compactor = Some(Compactor::new(bytes).keep_recent_n(2));
2338
2339        core.maybe_compact(1).await;
2340        assert!(
2341            core.messages[0].is_compaction_summary,
2342            "compaction must fire when bytes == threshold (bytes is NOT less than threshold)"
2343        );
2344    }
2345
2346    #[tokio::test]
2347    async fn maybe_compact_uses_token_threshold_intra_turn() {
2348        // Goal-330 regression: intra-turn maybe_compact must use the
2349        // token-based threshold when `last_prompt_tokens` is available,
2350        // even if the char threshold is high enough that it alone would
2351        // NOT fire.
2352        use crate::compact::Compactor;
2353        use crate::llm::{Completion, MockProvider};
2354        let hooks = crate::hooks::HookRegistry::new();
2355        let messages = vec![
2356            Message::system("sys".to_string()),
2357            Message::user("msg1".to_string()),
2358            Message::assistant("rep1".to_string()),
2359            Message::user("msg2".to_string()),
2360            Message::assistant("rep2".to_string()),
2361            Message::user("msg3".to_string()),
2362        ];
2363        let provider = Arc::new(MockProvider::new(vec![Completion {
2364            content: "token-threshold triggered summary".to_string(),
2365            tool_calls: vec![],
2366            finish_reason: Some("stop".to_string()),
2367            usage: None,
2368            reasoning_content: None,
2369        }]));
2370        let mut core = make_test_core(messages, &hooks);
2371        core.llm = provider;
2372        // Set high char threshold (would NOT fire on this transcript)
2373        // and low token threshold (WILL fire when last_prompt_tokens is set).
2374        let bytes = crate::compact::Compactor::estimate_bytes(&core.messages);
2375        core.compactor = Some(
2376            Compactor::new(bytes + 1000) // char threshold too high
2377                .threshold_prompt_tokens(500) // low token threshold
2378                .keep_recent_n(2),
2379        );
2380        // Set last_prompt_tokens above the token threshold.
2381        core.last_prompt_tokens = 500;
2382
2383        core.maybe_compact(1).await;
2384        assert!(
2385            core.messages[0].is_compaction_summary,
2386            "compaction must fire via token threshold even though chars are below char threshold"
2387        );
2388    }
2389
2390    /// Kills: `delete ! in RunCore<'a>::execute_tool_calls` at line 306.
2391    ///
2392    /// When `exploring_plan_mode == false` and the registry's mode is Plan,
2393    /// calling any tool (other than enter/exit plan mode) must return an error
2394    /// asking the agent to call `enter_plan_mode` first.
2395    ///
2396    /// With the mutant (`!` deleted), the condition becomes:
2397    ///   `if self.exploring_plan_mode.load() && …`
2398    /// which is FALSE when not in plan mode, so the guard is skipped and the
2399    /// tool call proceeds instead of returning the expected error.
2400    #[tokio::test]
2401    async fn execute_tool_calls_plan_mode_guard_when_not_exploring() {
2402        use crate::llm::ToolCall;
2403        use crate::permissions::{PermissionMode, PermissionsConfig};
2404        use std::sync::atomic::AtomicBool;
2405
2406        let hooks = crate::hooks::HookRegistry::new();
2407        let tools = Arc::new(crate::tools::ToolRegistry::default().with_permissions(
2408            PermissionsConfig {
2409                mode: PermissionMode::Plan {
2410                    pre_plan_mode: Box::new(PermissionMode::Default),
2411                    bypass_available: false,
2412                },
2413                layers: vec![],
2414            },
2415        ));
2416
2417        let mut core = make_test_core(vec![], &hooks);
2418        core.tools = tools;
2419        // Explicitly not in plan-mode exploration.
2420        core.exploring_plan_mode = Arc::new(AtomicBool::new(false));
2421
2422        let call = ToolCall {
2423            id: "tc1".to_string(),
2424            name: "Write".to_string(),
2425            arguments: serde_json::json!({}),
2426        };
2427
2428        let results = core.execute_tool_calls(&[call]).await;
2429
2430        assert_eq!(results.len(), 1, "should produce exactly one outcome");
2431        assert!(
2432            results[0].result.contains("enter_plan_mode"),
2433            "must tell the agent to call enter_plan_mode first; got: {}",
2434            results[0].result,
2435        );
2436    }
2437
2438    #[tokio::test]
2439    async fn maybe_compact_kept_count_is_correct() {
2440        // Kills: `replace - with + in maybe_compact` at line 253.
2441        // `kept = kept_before - removed` should equal the number of messages remaining.
2442        // With `- → +`, `kept = kept_before + removed` which would be wrong.
2443        use crate::compact::Compactor;
2444        use crate::event::AgentEvent;
2445        use crate::llm::{Completion, MockProvider};
2446        use tokio::sync::mpsc;
2447
2448        let hooks = crate::hooks::HookRegistry::new();
2449        let messages = vec![
2450            Message::system("sys".to_string()),
2451            Message::user("msg1".to_string()),
2452            Message::assistant("rep1".to_string()),
2453            Message::user("msg2".to_string()),
2454            Message::assistant("rep2".to_string()),
2455            Message::user("last".to_string()),
2456        ];
2457        let kept_before = messages.len();
2458        let provider = Arc::new(MockProvider::new(vec![Completion {
2459            content: "summary".to_string(),
2460            tool_calls: vec![],
2461            finish_reason: Some("stop".to_string()),
2462            usage: None,
2463            reasoning_content: None,
2464        }]));
2465        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2466        let mut core = make_test_core(messages, &hooks);
2467        core.llm = provider;
2468        core.events = Some(tx);
2469        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2470
2471        core.maybe_compact(1).await;
2472
2473        // Drain events to find Compacted
2474        let mut found_compacted = false;
2475        while let Ok(ev) = rx.try_recv() {
2476            if let AgentEvent::Compacted { removed, kept, .. } = ev {
2477                assert_eq!(
2478                    kept + removed,
2479                    kept_before,
2480                    "kept + removed must equal the original message count"
2481                );
2482                // kept must be the actual remaining messages (after compaction, first is summary)
2483                assert_eq!(
2484                    kept,
2485                    kept_before - removed,
2486                    "kept must be kept_before - removed, not kept_before + removed"
2487                );
2488                found_compacted = true;
2489            }
2490        }
2491        assert!(found_compacted, "a Compacted event must have been emitted");
2492    }
2493
2494    // ========================================================================
2495    // Circuit breaker tests (Goal 331)
2496    // ========================================================================
2497
2498    #[tokio::test]
2499    async fn maybe_compact_circuit_breaker_skips_when_failures_exceed_threshold() {
2500        // When consecutive_compact_failures >= MAX_CONSECUTIVE_COMPACT_FAILURES,
2501        // maybe_compact must emit CircuitBreaker and return without compacting.
2502        use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
2503        use crate::event::AgentEvent::{Compacted, CompactionSkipped};
2504        use crate::event::CompactionSkipReason;
2505        use tokio::sync::mpsc;
2506
2507        let hooks = crate::hooks::HookRegistry::new();
2508        let messages = vec![
2509            Message::system("sys".to_string()),
2510            Message::user("long enough to trigger compaction".to_string()),
2511            Message::assistant("response".to_string()),
2512        ];
2513        let (tx, mut rx) = mpsc::unbounded_channel();
2514        let mut core = make_test_core(messages, &hooks);
2515        core.events = Some(tx);
2516        core.compactor = Some(Compactor::new(0)); // 0 → always compact
2517        core.consecutive_compact_failures = MAX_CONSECUTIVE_COMPACT_FAILURES;
2518
2519        core.maybe_compact(0).await;
2520
2521        // No Compacted event; only CompactionSkipped::CircuitBreaker
2522        let mut found_breaker = false;
2523        while let Ok(ev) = rx.try_recv() {
2524            match ev {
2525                Compacted { .. } => panic!("circuit breaker should prevent compaction"),
2526                CompactionSkipped { reason, .. } => {
2527                    assert_eq!(reason, CompactionSkipReason::CircuitBreaker);
2528                    found_breaker = true;
2529                }
2530                _ => {}
2531            }
2532        }
2533        assert!(found_breaker, "must emit CircuitBreaker skip event");
2534        assert_eq!(
2535            core.messages.len(),
2536            3,
2537            "must not compact when breaker is tripped"
2538        );
2539    }
2540
2541    #[tokio::test]
2542    async fn maybe_compact_circuit_breaker_resets_on_success() {
2543        // After a successful compaction, consecutive_compact_failures must be 0.
2544        use crate::compact::Compactor;
2545        use crate::llm::{Completion, MockProvider};
2546
2547        let hooks = crate::hooks::HookRegistry::new();
2548        let messages = vec![
2549            Message::system("sys".to_string()),
2550            Message::user("msg1".to_string()),
2551            Message::assistant("rep1".to_string()),
2552            Message::user("msg2".to_string()),
2553            Message::assistant("rep2".to_string()),
2554        ];
2555        let provider = Arc::new(MockProvider::new(vec![Completion {
2556            content: "summary".to_string(),
2557            tool_calls: vec![],
2558            finish_reason: Some("stop".to_string()),
2559            usage: None,
2560            reasoning_content: None,
2561        }]));
2562        let mut core = make_test_core(messages, &hooks);
2563        core.llm = provider;
2564        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2565        core.consecutive_compact_failures = 2; // near threshold but not reached
2566
2567        core.maybe_compact(1).await;
2568
2569        assert_eq!(
2570            core.consecutive_compact_failures, 0,
2571            "breaker must reset on success"
2572        );
2573    }
2574
2575    #[tokio::test]
2576    async fn maybe_compact_circuit_breaker_increments_on_error() {
2577        // When compaction returns an error, consecutive_compact_failures must increment.
2578        use crate::compact::Compactor;
2579        use crate::error::Error;
2580        use crate::event::AgentEvent::CompactionSkipped;
2581        use crate::event::CompactionSkipReason;
2582        use crate::llm::MockProvider;
2583        use tokio::sync::mpsc;
2584
2585        let hooks = crate::hooks::HookRegistry::new();
2586        // Need at least 2 conversational msgs in "older" (pre-keep_recent_n) for compaction to fire.
2587        let messages = vec![
2588            Message::system("sys".to_string()),
2589            Message::user("msg1".to_string()),
2590            Message::assistant("rep1".to_string()),
2591            Message::user("msg2".to_string()),
2592            Message::assistant("rep2".to_string()),
2593        ];
2594        // MockProvider that errors on the first complete() call → compaction fails.
2595        let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![Error::Llm {
2596            provider: "mock".into(),
2597            message: "simulated compaction failure".into(),
2598        }]));
2599        let (tx, mut rx) = mpsc::unbounded_channel();
2600        let mut core = make_test_core(messages, &hooks);
2601        core.llm = provider;
2602        core.events = Some(tx);
2603        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2604        core.consecutive_compact_failures = 0;
2605
2606        core.maybe_compact(1).await;
2607
2608        assert_eq!(
2609            core.consecutive_compact_failures, 1,
2610            "breaker must increment on error"
2611        );
2612        // Must emit CompactionSkipped::Error
2613        let mut found_error = false;
2614        while let Ok(ev) = rx.try_recv() {
2615            if let CompactionSkipped { reason, .. } = ev {
2616                assert_eq!(reason, CompactionSkipReason::Error);
2617                found_error = true;
2618            }
2619        }
2620        assert!(found_error, "must emit CompactionSkipped::Error event");
2621    }
2622
2623    #[tokio::test]
2624    async fn maybe_compact_circuit_breaker_accumulates_to_threshold() {
2625        // Three consecutive failures must trip the breaker (MAX_CONSECUTIVE_COMPACT_FAILURES = 3).
2626        use crate::compact::{Compactor, MAX_CONSECUTIVE_COMPACT_FAILURES};
2627        use crate::error::Error;
2628        use crate::event::AgentEvent::{Compacted, CompactionSkipped};
2629        use crate::event::CompactionSkipReason;
2630        use crate::llm::MockProvider;
2631        use tokio::sync::mpsc;
2632
2633        let hooks = crate::hooks::HookRegistry::new();
2634        // Need at least 2 conversational msgs in older for compaction to fire.
2635        let messages = vec![
2636            Message::system("sys".to_string()),
2637            Message::user("msg1".to_string()),
2638            Message::assistant("rep1".to_string()),
2639            Message::user("msg2".to_string()),
2640            Message::assistant("rep2".to_string()),
2641        ];
2642        // MockProvider errors on every complete() call → compaction always fails.
2643        let mk_err = || Error::Llm {
2644            provider: "mock".into(),
2645            message: "simulated compaction failure".into(),
2646        };
2647        let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![
2648            mk_err(),
2649            mk_err(),
2650            mk_err(),
2651            mk_err(),
2652        ]));
2653        let (tx, mut rx) = mpsc::unbounded_channel();
2654        let mut core = make_test_core(messages, &hooks);
2655        core.llm = provider.clone();
2656        core.events = Some(tx);
2657        core.compactor = Some(Compactor::new(0).keep_recent_n(2));
2658        core.consecutive_compact_failures = 0;
2659
2660        // First two calls: each increments the counter
2661        for step in 0..(MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize {
2662            core.maybe_compact(step).await;
2663        }
2664        assert_eq!(
2665            core.consecutive_compact_failures,
2666            MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
2667            "after {} failures, counter should be {}",
2668            MAX_CONSECUTIVE_COMPACT_FAILURES - 1,
2669            MAX_CONSECUTIVE_COMPACT_FAILURES - 1
2670        );
2671
2672        // Third call: reaches threshold → breaker trips, no compaction attempted
2673        // Drain events before third call
2674        while rx.try_recv().is_ok() {}
2675        core.maybe_compact((MAX_CONSECUTIVE_COMPACT_FAILURES - 1) as usize)
2676            .await;
2677
2678        // Counter stays at threshold (doesn't overflow)
2679        assert_eq!(
2680            core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
2681            "counter must not exceed threshold"
2682        );
2683
2684        // Fourth call: breaker already tripped → emits CircuitBreaker, no counter change
2685        let (tx2, mut rx2) = mpsc::unbounded_channel();
2686        core.events = Some(tx2);
2687        core.maybe_compact(MAX_CONSECUTIVE_COMPACT_FAILURES as usize)
2688            .await;
2689
2690        assert_eq!(
2691            core.consecutive_compact_failures, MAX_CONSECUTIVE_COMPACT_FAILURES,
2692            "breaker tripped: counter should stay at max"
2693        );
2694
2695        // Must have emitted CircuitBreaker (no further increments)
2696        let mut breaker_count = 0;
2697        while let Ok(ev) = rx2.try_recv() {
2698            if let CompactionSkipped { ref reason, .. } = ev {
2699                assert_eq!(*reason, CompactionSkipReason::CircuitBreaker);
2700                breaker_count += 1;
2701            }
2702            if let Compacted { .. } = ev {
2703                panic!("circuit breaker should prevent compaction once tripped");
2704            }
2705        }
2706        assert!(
2707            breaker_count >= 1,
2708            "must emit CircuitBreaker event when tripped"
2709        );
2710    }
2711
2712    // ========================================================================
2713    // run_inner integration tests
2714    // ========================================================================
2715    // Kills: `delete ! in RunCore<'a>::execute_tool_calls` at line 306.
2716    // These tests call run_inner() directly to kill mutants deep inside the
2717    // main agent loop that cannot be reached by unit tests of sub-functions.
2718    // ========================================================================
2719
2720    /// Helper: build a RunCore that can run through `run_inner`.
2721    /// Messages start with a single user turn; LLM and max_steps are caller-set.
2722    fn make_run_core_for_inner<'a>(
2723        messages: Vec<Message>,
2724        hooks: &'a crate::hooks::HookRegistry,
2725        provider: Arc<crate::llm::MockProvider>,
2726        max_steps: usize,
2727    ) -> RunCore<'a> {
2728        use std::sync::atomic::AtomicBool;
2729        RunCore {
2730            messages: Arc::new(messages),
2731            llm: provider,
2732            tools: Arc::new(crate::tools::ToolRegistry::default()),
2733            max_steps,
2734            max_transcript_chars: None,
2735            events: None,
2736            streaming: false,
2737            compactor: None,
2738            microcompactor: None,
2739            permission_hook: None,
2740            hooks,
2741            total_llm_latency_ms: 0,
2742            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
2743            shutdown_token: None,
2744            mailbox: None,
2745            stuck_window: 3,
2746            stuck_error_rate: 1.0,
2747            turn: 0,
2748            globs_skills: vec![],
2749            prompt_segments: None,
2750            static_breakdown: StaticBreakdownCache::default(),
2751            last_prompt_tokens: 0,
2752            consecutive_compact_failures: 0,
2753            wall_timeout_secs: 0,
2754            wall_start: None,
2755        }
2756    }
2757
2758    /// Kills: `replace >= with <` at line 579.
2759    ///
2760    /// When `chars >= limit` after trimming, `run_inner` must return
2761    /// `TranscriptLimit`.  With the mutant (`< limit`), the condition is
2762    /// inverted and `TranscriptLimit` would fire when chars is BELOW the
2763    /// limit — the happy path.
2764    #[tokio::test]
2765    async fn run_inner_returns_transcript_limit_when_chars_at_or_above_limit() {
2766        use crate::agent::FinishReason;
2767
2768        let hooks = crate::hooks::HookRegistry::new();
2769        // System message "hello" = 5 chars; set limit = 3 → 5 >= 3 → TranscriptLimit.
2770        let messages = vec![Message::system("hello".to_string())];
2771        let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
2772        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
2773        core.max_transcript_chars = Some(3); // 5 chars > 3 → fires before LLM call
2774
2775        let outcome = core.run_inner().await.expect("run_inner must not error");
2776        assert!(
2777            matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
2778            "expected TranscriptLimit, got {:?}",
2779            outcome.finish_reason,
2780        );
2781    }
2782
2783    /// Kills: `delete !` at line 679.
2784    ///
2785    /// When `reasoning_content` is non-empty, `run_inner` must emit a
2786    /// `AgentEvent::Reasoning` event.  With the mutant (delete `!`), the
2787    /// guard becomes `if reasoning.is_empty()`, so no event is emitted for
2788    /// non-empty reasoning.
2789    #[tokio::test]
2790    async fn run_inner_emits_reasoning_event_when_reasoning_nonempty() {
2791        use crate::event::AgentEvent;
2792        use crate::llm::Completion;
2793        use tokio::sync::mpsc;
2794
2795        let hooks = crate::hooks::HookRegistry::new();
2796        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2797            content: "done".to_string(),
2798            tool_calls: vec![],
2799            finish_reason: Some("stop".to_string()),
2800            usage: None,
2801            reasoning_content: Some("I thought about it".to_string()),
2802        }]));
2803        let messages = vec![Message::user("hello".to_string())];
2804        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2805        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
2806        core.events = Some(tx);
2807
2808        let outcome = core.run_inner().await.expect("run_inner must not error");
2809        drop(outcome); // satisfy unused-var lint
2810
2811        let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2812        assert!(
2813            events.iter().any(|e| matches!(e, AgentEvent::Reasoning { .. })),
2814            "AgentEvent::Reasoning must be emitted when reasoning_content is non-empty; got: {events:?}",
2815        );
2816    }
2817
2818    /// Kills: `replace match guard … with false` at line 700:32 AND
2819    ///        `replace != with ==` at line 700:49.
2820    ///
2821    /// When the LLM returns `finish_reason = Some("rate_limited")` with no
2822    /// tool calls, `run_inner` must classify it as `FinishReason::ProviderStop`.
2823    /// Both mutants break the match guard so "rate_limited" falls to the
2824    /// default `NoMoreToolCalls` arm instead.
2825    #[tokio::test]
2826    async fn run_inner_provider_stop_for_nonstandard_finish_reason() {
2827        use crate::agent::FinishReason;
2828        use crate::llm::Completion;
2829
2830        let hooks = crate::hooks::HookRegistry::new();
2831        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2832            content: "provider paused".to_string(),
2833            tool_calls: vec![],
2834            finish_reason: Some("rate_limited".to_string()),
2835            usage: None,
2836            reasoning_content: None,
2837        }]));
2838        let messages = vec![Message::user("hello".to_string())];
2839        let core = make_run_core_for_inner(messages, &hooks, provider, 1);
2840
2841        let outcome = core.run_inner().await.expect("run_inner must not error");
2842        assert!(
2843            matches!(
2844                &outcome.finish_reason,
2845                FinishReason::ProviderStop(r) if r == "rate_limited"
2846            ),
2847            "expected ProviderStop(\"rate_limited\"), got {:?}",
2848            outcome.finish_reason,
2849        );
2850    }
2851
2852    /// Complementary: `finish_reason = Some("stop")` → `NoMoreToolCalls`.
2853    /// Pins the pass-through branch of the same match guard.
2854    #[tokio::test]
2855    async fn run_inner_no_provider_stop_for_stop_finish_reason() {
2856        use crate::agent::FinishReason;
2857        use crate::llm::Completion;
2858
2859        let hooks = crate::hooks::HookRegistry::new();
2860        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2861            content: "all done".to_string(),
2862            tool_calls: vec![],
2863            finish_reason: Some("stop".to_string()),
2864            usage: None,
2865            reasoning_content: None,
2866        }]));
2867        let messages = vec![Message::user("hello".to_string())];
2868        let core = make_run_core_for_inner(messages, &hooks, provider, 1);
2869
2870        let outcome = core.run_inner().await.expect("run_inner must not error");
2871        assert!(
2872            matches!(outcome.finish_reason, FinishReason::NoMoreToolCalls),
2873            "expected NoMoreToolCalls, got {:?}",
2874            outcome.finish_reason,
2875        );
2876    }
2877
2878    /// Goal 353 — Invariant #7 (finish reasons are data, not errors).
2879    ///
2880    /// HEADLINE regression: a mid-stream cancellation (the provider returns
2881    /// `Err(Error::Cancelled)` from the in-flight LLM call) must be translated
2882    /// into a `FinishReason::Cancelled` *outcome* — NOT bubbled as `Err` — so
2883    /// the caller (`runtime.rs`) persists the partial transcript, runs
2884    /// `emit_turn_messages` + compaction, and lets the `SessionEnd` hook
2885    /// suppression for cancellation fire. Before this fix the error rode `?`
2886    /// out of `run_inner` and the transcript save was skipped.
2887    #[tokio::test]
2888    async fn cancelled_mid_stream_persists_transcript_and_finish_reason() {
2889        use crate::agent::FinishReason;
2890        use crate::event::AgentEvent;
2891        use tokio::sync::mpsc;
2892
2893        let hooks = crate::hooks::HookRegistry::new();
2894        // The error queue is consumed BEFORE scripted completions, so the
2895        // first LLM call returns `Err(Error::Cancelled)` — simulating the
2896        // provider aborting mid-stream when the shutdown token fires between
2897        // SSE chunks (see `anthropic.rs` / `openai.rs` stream loops).
2898        let provider = Arc::new(
2899            crate::llm::MockProvider::new(vec![]).with_errors(vec![crate::error::Error::Cancelled]),
2900        );
2901        let messages = vec![Message::user("hello".to_string())];
2902        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
2903        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
2904        core.events = Some(tx);
2905        // Provide a token but leave it UN-cancelled: check_shutdown must NOT
2906        // fire — the Cancelled outcome has to come from the provider call.
2907        core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());
2908
2909        let outcome = core.run_inner().await.expect("run_inner must not error");
2910        assert!(
2911            matches!(outcome.finish_reason, FinishReason::Cancelled),
2912            "expected FinishReason::Cancelled, got {:?}",
2913            outcome.finish_reason,
2914        );
2915        // The cancellation happened DURING the first step's LLM call (the
2916        // loop's first iteration is step = 1), so the in-flight step is
2917        // counted. This is deliberately `step`, not `step - 1` like
2918        // check_shutdown — the call was in progress, not pre-call.
2919        assert_eq!(
2920            outcome.steps, 1,
2921            "mid-stream cancel counts the in-flight step (not step - 1)",
2922        );
2923
2924        // The seeded transcript must survive into the outcome.
2925        assert_eq!(outcome.messages.len(), 1, "transcript must be preserved");
2926        assert_eq!(
2927            outcome.messages[0].role,
2928            crate::message::Role::User,
2929            "seeded user message must be in the outcome transcript",
2930        );
2931
2932        let events: Vec<AgentEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
2933        assert!(
2934            events.iter().any(|e| matches!(
2935                e,
2936                AgentEvent::TurnFinished { reason, steps }
2937                    if reason.as_str() == "cancelled" && *steps == 1
2938            )),
2939            "TurnFinished(cancelled) must be emitted with the in-flight step; got: {events:?}",
2940        );
2941    }
2942
2943    /// Goal 382 — HEADLINE: a stream-interrupted completion (the provider
2944    /// returns `Ok` with `finish_reason == Some("interrupted")` and partial
2945    /// content, as the openai.rs / anthropic.rs SSE parsers now do on a
2946    /// mid-stream network drop) must be persisted as a partial assistant
2947    /// message and finish the turn as `FinishReason::Cancelled` — NOT
2948    /// dropped. Before this goal the parser returned `Err` on the read
2949    /// error, which short-circuited `emit_turn_messages` and left the
2950    /// transcript ending with an unanswered user turn.
2951    #[tokio::test]
2952    async fn interrupted_completion_persists_partial_content_as_cancelled() {
2953        use crate::agent::FinishReason;
2954        use crate::llm::Completion;
2955        use crate::message::Role;
2956
2957        let hooks = crate::hooks::HookRegistry::new();
2958        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
2959            content: "partial reply…".to_string(),
2960            tool_calls: vec![],
2961            finish_reason: Some("interrupted".to_string()),
2962            usage: None,
2963            reasoning_content: Some("half a thought".to_string()),
2964        }]));
2965        let messages = vec![Message::user("hello".to_string())];
2966        let core = make_run_core_for_inner(messages, &hooks, provider, 3);
2967
2968        let outcome = core.run_inner().await.expect("run_inner must not error");
2969        assert!(
2970            matches!(outcome.finish_reason, FinishReason::Cancelled),
2971            "interrupted completion must finish as Cancelled, got {:?}",
2972            outcome.finish_reason,
2973        );
2974
2975        // The partial reply must become the final_message so the caller's
2976        // persistence path treats this as a real (partial) turn.
2977        assert_eq!(
2978            outcome.final_message.as_deref(),
2979            Some("partial reply…"),
2980            "the partial reply must become the final_message",
2981        );
2982
2983        // The partial assistant message (content + reasoning) must ride the
2984        // existing make_cancelled_outcome → new_messages path so
2985        // runtime.rs:emit_turn_messages writes it to disk.
2986        let assistant_msgs: Vec<&Message> = outcome
2987            .messages
2988            .iter()
2989            .filter(|m| m.role == Role::Assistant)
2990            .collect();
2991        assert_eq!(
2992            assistant_msgs.len(),
2993            1,
2994            "exactly one partial assistant message must be pushed",
2995        );
2996        assert_eq!(assistant_msgs[0].content, "partial reply…");
2997        assert_eq!(
2998            assistant_msgs[0].reasoning_content.as_deref(),
2999            Some("half a thought"),
3000            "reasoning must be attached to the partial assistant message",
3001        );
3002    }
3003
3004    /// Goal 353 — regression guard for the PRE-EXISTING path.
3005    ///
3006    /// When the shutdown token is ALREADY cancelled at loop top (before any
3007    /// LLM call), `check_shutdown` handles it and reports `step - 1` (0 on the
3008    /// first iteration). This pins that path so reviewers can see both arms
3009    /// (between-steps vs mid-stream) are covered and deliberately distinct.
3010    #[tokio::test]
3011    async fn cancelled_between_steps_uses_check_shutdown_path() {
3012        use crate::agent::FinishReason;
3013
3014        let hooks = crate::hooks::HookRegistry::new();
3015        // Empty provider: the LLM must NEVER be called — check_shutdown fires
3016        // at loop top before the call, so a call here would fail loudly
3017        // (MockProvider errors when its scripted queue is drained).
3018        let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
3019        let messages = vec![Message::user("hello".to_string())];
3020        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3021        core.shutdown_token = Some(tokio_util::sync::CancellationToken::new());
3022        // Cancel BEFORE run_inner: fires check_shutdown on step 1's loop top.
3023        core.shutdown_token
3024            .as_ref()
3025            .expect("token just set")
3026            .cancel();
3027
3028        let outcome = core.run_inner().await.expect("run_inner must not error");
3029        assert!(
3030            matches!(outcome.finish_reason, FinishReason::Cancelled),
3031            "expected FinishReason::Cancelled, got {:?}",
3032            outcome.finish_reason,
3033        );
3034        assert_eq!(
3035            outcome.steps, 0,
3036            "between-steps cancel happens before any LLM call → 0 steps",
3037        );
3038    }
3039
3040    /// Goal 353 — negative guard.
3041    ///
3042    /// A NON-Cancelled error from the provider must still bubble out of
3043    /// `run_inner` as `Err`. The new match only swallows `Cancelled`; any
3044    /// other error keeps the original `?`-style propagation so genuine
3045    /// failures are not silently converted into finished turns.
3046    #[tokio::test]
3047    async fn non_cancelled_error_still_bubbles() {
3048        let hooks = crate::hooks::HookRegistry::new();
3049        // Error::Llm is not retried by the run path (retry handling lives in
3050        // the provider's own RetryPolicy, which MockProvider does not have),
3051        // so it reaches run_inner's match and must fall through to the Err arm.
3052        let provider = Arc::new(crate::llm::MockProvider::new(vec![]).with_errors(vec![
3053            crate::error::Error::Llm {
3054                provider: "mock".to_string(),
3055                message: "injected non-cancelled error".to_string(),
3056            },
3057        ]));
3058        let messages = vec![Message::user("hello".to_string())];
3059        let core = make_run_core_for_inner(messages, &hooks, provider, 1);
3060
3061        let err = match core.run_inner().await {
3062            Ok(_) => panic!("run_inner must error for non-Cancelled provider errors"),
3063            Err(e) => e,
3064        };
3065        assert!(
3066            matches!(err, crate::error::Error::Llm { .. }),
3067            "expected Error::Llm, got {err:?}",
3068        );
3069    }
3070
3071    /// Kills (all three together):
3072    ///   `replace && with ||` at line 820:43  — stuck guard uses `||`
3073    ///   `replace / with *`   at line 827:51  — rate = count * window (huge)
3074    ///   `replace += with *=` at line 833:59  — count never increments
3075    ///
3076    /// Run the agent with 3 consecutive single-tool-call steps that all fail
3077    /// with "ERROR: …" (unknown tool).  With `stuck_window = 3` and
3078    /// `stuck_error_rate = 1.0`, stuck detection must fire on step 3.
3079    ///
3080    /// Each mutant breaks a different aspect of stuck detection:
3081    /// - `|| ` guard: fires after the FIRST error (window not yet full)
3082    /// - `*` rate:    fires after the first error (rate = 1 * 3 = 3 ≥ 1.0)
3083    /// - `*=` count:  all error_count = 0 → rate = 0 → stuck NEVER fires
3084    #[tokio::test]
3085    async fn run_inner_stuck_detection_fires_after_window_of_errors() {
3086        use crate::agent::FinishReason;
3087        use crate::llm::{Completion, ToolCall};
3088
3089        let hooks = crate::hooks::HookRegistry::new();
3090        // Three steps each requesting the same non-existent tool → all fail.
3091        let make_tc_completion = |id: &str| Completion {
3092            content: String::new(),
3093            tool_calls: vec![ToolCall {
3094                id: id.to_string(),
3095                name: "NonExistentTool".to_string(),
3096                arguments: serde_json::json!({}),
3097            }],
3098            finish_reason: None,
3099            usage: None,
3100            reasoning_content: None,
3101        };
3102        let provider = Arc::new(crate::llm::MockProvider::new(vec![
3103            make_tc_completion("tc1"),
3104            make_tc_completion("tc2"),
3105            make_tc_completion("tc3"),
3106        ]));
3107        let messages = vec![Message::user("keep trying".to_string())];
3108        let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
3109        core.stuck_window = 3;
3110        core.stuck_error_rate = 1.0;
3111
3112        let outcome = core.run_inner().await.expect("run_inner must not error");
3113        assert!(
3114            matches!(outcome.finish_reason, FinishReason::Stuck { .. }),
3115            "expected Stuck after 3 consecutive errors, got {:?}",
3116            outcome.finish_reason,
3117        );
3118    }
3119
3120    /// Kills: `replace && with ||` at line 820:43.
3121    ///
3122    /// The guard is `stuck_finish.is_none() && recent_errors.len() == stuck_window`.
3123    /// With `||`, it fires whenever `stuck_finish.is_none()` is true (always before
3124    /// stuck is set), so it triggers on every step — not just when the window fills.
3125    ///
3126    /// Setup: `stuck_window=3`, `stuck_error_rate=0.5`, `max_steps=2`.
3127    /// After 2 errors the window is NOT yet full (len=2 < window=3).
3128    ///   • Original (`&&`): condition false → no stuck → `BudgetExceeded`.
3129    ///   • Mutant (`||`): condition fires at step 2 (rate = 2/3 = 0.666 ≥ 0.5) → `Stuck`.
3130    #[tokio::test]
3131    async fn run_inner_stuck_fires_only_after_window_full() {
3132        use crate::agent::FinishReason;
3133        use crate::llm::{Completion, ToolCall};
3134
3135        let hooks = crate::hooks::HookRegistry::new();
3136        let make_tc = |id: &str| Completion {
3137            content: String::new(),
3138            tool_calls: vec![ToolCall {
3139                id: id.to_string(),
3140                name: "NonExistentTool".to_string(),
3141                arguments: serde_json::json!({}),
3142            }],
3143            finish_reason: None,
3144            usage: None,
3145            reasoning_content: None,
3146        };
3147        // Two steps — both produce errors, but the window (size 3) never fills.
3148        let provider = Arc::new(crate::llm::MockProvider::new(vec![
3149            make_tc("tc1"),
3150            make_tc("tc2"),
3151        ]));
3152        let messages = vec![Message::user("go".to_string())];
3153        let mut core = make_run_core_for_inner(messages, &hooks, provider, 2);
3154        core.stuck_window = 3;
3155        core.stuck_error_rate = 0.5; // 2/3 ≈ 0.666 ≥ 0.5 with mutant, but window not full
3156
3157        let outcome = core.run_inner().await.expect("run_inner must not error");
3158        assert!(
3159            matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
3160            "expected BudgetExceeded (window not yet full), got {:?}",
3161            outcome.finish_reason,
3162        );
3163    }
3164
3165    /// Kills: `replace / with *` at line 827:51.
3166    ///
3167    /// `rate = error_count as f64 / stuck_window as f64` vs `* stuck_window`.
3168    /// With `window=3`, `error_count=1`, `stuck_error_rate=0.5`:
3169    ///   • Original: 1/3 ≈ 0.333 < 0.5 → no stuck → `BudgetExceeded`.
3170    ///   • Mutant:   1×3 = 3.0 ≥ 0.5   → stuck fires.
3171    ///
3172    /// We need exactly 1 error in a full window of 3: two successful SuccessTool
3173    /// calls followed by one NonExistentTool error fills the window with 1 error.
3174    #[tokio::test]
3175    async fn run_inner_stuck_rate_uses_division_not_multiplication() {
3176        use crate::agent::FinishReason;
3177        use crate::llm::ToolSpec;
3178        use crate::llm::{Completion, ToolCall};
3179        use crate::tools::{Tool, ToolRegistry};
3180        use async_trait::async_trait;
3181
3182        struct SuccessTool;
3183
3184        #[async_trait]
3185        impl Tool for SuccessTool {
3186            fn spec(&self) -> ToolSpec {
3187                ToolSpec {
3188                    name: "SuccessTool".to_string(),
3189                    description: "Returns ok".to_string(),
3190                    parameters: serde_json::json!({ "type": "object", "properties": {} }),
3191                }
3192            }
3193            async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
3194                Ok("ok".to_string())
3195            }
3196        }
3197
3198        let hooks = crate::hooks::HookRegistry::new();
3199        let registry = ToolRegistry::default().register(Arc::new(SuccessTool));
3200
3201        let make_ok = |id: &str| Completion {
3202            content: String::new(),
3203            tool_calls: vec![ToolCall {
3204                id: id.to_string(),
3205                name: "SuccessTool".to_string(),
3206                arguments: serde_json::json!({}),
3207            }],
3208            finish_reason: None,
3209            usage: None,
3210            reasoning_content: None,
3211        };
3212        let make_err = |id: &str| Completion {
3213            content: String::new(),
3214            tool_calls: vec![ToolCall {
3215                id: id.to_string(),
3216                name: "NonExistentTool".to_string(),
3217                arguments: serde_json::json!({}),
3218            }],
3219            finish_reason: None,
3220            usage: None,
3221            reasoning_content: None,
3222        };
3223        // success, success, error → window=3 full with 1 error
3224        let provider = Arc::new(crate::llm::MockProvider::new(vec![
3225            make_ok("tc1"),
3226            make_ok("tc2"),
3227            make_err("tc3"),
3228        ]));
3229        let messages = vec![Message::user("test".to_string())];
3230        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3231        core.tools = Arc::new(registry);
3232        core.stuck_window = 3;
3233        core.stuck_error_rate = 0.5; // 1/3 ≈ 0.333 < 0.5 → no stuck (orig); 1×3=3.0 ≥ 0.5 (mutant)
3234
3235        let outcome = core.run_inner().await.expect("run_inner must not error");
3236        assert!(
3237            matches!(outcome.finish_reason, FinishReason::BudgetExceeded),
3238            "expected BudgetExceeded (rate below threshold), got {:?}",
3239            outcome.finish_reason,
3240        );
3241    }
3242
3243    /// Kills: `replace || with &&` at line 750:57 AND
3244    ///        `replace == with !=` at line 750:69.
3245    ///
3246    /// `DENIAL_LIMIT_SENTINEL = "ERROR_DENIAL_LIMIT:"` does NOT start with "ERROR: "
3247    /// (note the space), so `is_error` is true only via the `|| o.result == DENIAL_LIMIT_SENTINEL`
3248    /// branch.
3249    ///
3250    /// - Mutant 750:57 (`&&` instead of `||`): `false && true = false` → wrong
3251    /// - Mutant 750:69 (`!=` instead of `==`): `false || false = false` → wrong
3252    ///
3253    /// Both mutants emit `AgentEvent::ToolResult { is_error: false }` for the sentinel,
3254    /// but the correct code must emit `is_error: true`.
3255    #[tokio::test]
3256    async fn run_inner_denial_sentinel_emits_is_error_true() {
3257        use crate::event::AgentEvent;
3258        use crate::llm::{Completion, ToolCall, ToolSpec};
3259        use crate::tools::{Tool, ToolRegistry};
3260        use async_trait::async_trait;
3261        use tokio::sync::mpsc;
3262
3263        struct DenialTool;
3264
3265        #[async_trait]
3266        impl Tool for DenialTool {
3267            fn spec(&self) -> ToolSpec {
3268                ToolSpec {
3269                    name: "DenialTool".to_string(),
3270                    description: "Returns PermissionDeniedLimit".to_string(),
3271                    parameters: serde_json::json!({ "type": "object", "properties": {} }),
3272                }
3273            }
3274            async fn execute(&self, _args: serde_json::Value) -> crate::error::Result<String> {
3275                Err(crate::error::Error::PermissionDeniedLimit {
3276                    name: "DenialTool".to_string(),
3277                })
3278            }
3279        }
3280
3281        let hooks = crate::hooks::HookRegistry::new();
3282        let registry = ToolRegistry::default().register(Arc::new(DenialTool));
3283        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
3284            content: String::new(),
3285            tool_calls: vec![ToolCall {
3286                id: "tc1".to_string(),
3287                name: "DenialTool".to_string(),
3288                arguments: serde_json::json!({}),
3289            }],
3290            finish_reason: None,
3291            usage: None,
3292            reasoning_content: None,
3293        }]));
3294
3295        let messages = vec![Message::user("trigger denial".to_string())];
3296        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3297        let mut core = make_run_core_for_inner(messages, &hooks, provider, 3);
3298        core.tools = Arc::new(registry);
3299        core.events = Some(tx);
3300
3301        let outcome = core.run_inner().await.expect("run_inner must not error");
3302        assert!(
3303            matches!(
3304                outcome.finish_reason,
3305                crate::agent::FinishReason::PermissionDenialLimit
3306            ),
3307            "expected PermissionDenialLimit, got {:?}",
3308            outcome.finish_reason,
3309        );
3310
3311        let mut events = vec![];
3312        while let Ok(e) = rx.try_recv() {
3313            events.push(e);
3314        }
3315        let denial_is_error = events.iter().find_map(|e| {
3316            if let AgentEvent::ToolResult { name, is_error, .. } = e {
3317                if name == "DenialTool" {
3318                    return Some(*is_error);
3319                }
3320            }
3321            None
3322        });
3323        assert_eq!(
3324            denial_is_error,
3325            Some(true),
3326            "ToolResult for DENIAL_LIMIT_SENTINEL must have is_error = true; events: {events:?}"
3327        );
3328    }
3329
3330    // ========================================================================
3331    // Goal-328: ContextBreakdown event emission
3332    // ========================================================================
3333
3334    /// `compute_breakdown` must populate `overhead = max(0, provider_total -
3335    /// local_sum)`. With `prompt_tokens = 1000` and a local sum of 700,
3336    /// the breakdown's overhead must be 300. The other buckets must match
3337    /// `static_breakdown` (cached at construction).
3338    #[test]
3339    fn compute_breakdown_overhead_is_provider_total_minus_local_sum() {
3340        use crate::llm::ContextBreakdown;
3341
3342        let hooks = crate::hooks::HookRegistry::new();
3343        // Seed a transcript long enough to make the conversation bucket non-zero.
3344        let messages = vec![
3345            Message::system("s".to_string()),
3346            Message::user("hello world this is a turn".to_string()),
3347            Message::assistant("ok".to_string()),
3348        ];
3349        let mut core = make_test_core(messages, &hooks);
3350        // Hand-build a static breakdown whose sum we can predict.
3351        core.static_breakdown = StaticBreakdownCache {
3352            system_prompt: 100,
3353            rules: 50,
3354            skills: 25,
3355            subagents: 10,
3356            tools: 15,
3357            mcp_dynamic: 0,
3358        };
3359        let breakdown = core.compute_breakdown(1000);
3360
3361        // Provider total - local_sum = 1000 - (100 + 50 + 25 + 10 + 15 + 0 + conversation).
3362        let local = breakdown.local_sum();
3363        let expected_overhead = 1000u32.saturating_sub(local);
3364        assert_eq!(
3365            breakdown.overhead, expected_overhead,
3366            "overhead must be provider_total - local_sum"
3367        );
3368        // Conversation must be > 0 (we seeded a non-trivial transcript).
3369        assert!(
3370            breakdown.conversation > 0,
3371            "conversation bucket must be non-zero for a seeded transcript"
3372        );
3373        // Static buckets must mirror the cache verbatim.
3374        assert_eq!(breakdown.system_prompt, 100);
3375        assert_eq!(breakdown.rules, 50);
3376        assert_eq!(breakdown.skills, 25);
3377        assert_eq!(breakdown.subagents, 10);
3378        assert_eq!(breakdown.tools, 15);
3379        assert_eq!(breakdown.mcp_dynamic, 0);
3380        // Sanity: the public type's helpers agree.
3381        assert_eq!(breakdown.local_sum(), local);
3382        assert_eq!(breakdown.total(), local + expected_overhead);
3383        // Silence unused-variable warning for ContextBreakdown import.
3384        let _ = ContextBreakdown::default();
3385    }
3386
3387    /// When the local sum exceeds the provider's reported total (which can
3388    /// happen with chars/4 over-estimation on dense CJK content), the
3389    /// overhead bucket must saturate to 0 — not wrap to a huge u32.
3390    #[test]
3391    fn compute_breakdown_overhead_saturates_at_zero_when_local_exceeds_provider() {
3392        let hooks = crate::hooks::HookRegistry::new();
3393        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3394        core.static_breakdown = StaticBreakdownCache {
3395            system_prompt: 5000,
3396            rules: 1000,
3397            skills: 0,
3398            subagents: 0,
3399            tools: 0,
3400            mcp_dynamic: 0,
3401        };
3402        let breakdown = core.compute_breakdown(100); // local sum ≫ 100
3403        assert_eq!(
3404            breakdown.overhead, 0,
3405            "overhead must saturate to 0 when local sum > provider total; got {}",
3406            breakdown.overhead
3407        );
3408    }
3409
3410    /// `emit_breakdown` consumes a `TokenUsage` and emits
3411    /// `AgentEvent::ContextBreakdown`. We assert the emitted event's
3412    /// bucket totals match the spec (overhead uses `max(input_tokens,
3413    /// cache_hit + cache_miss)` so Anthropic + OpenAI reporting
3414    /// differences are handled).
3415    #[tokio::test]
3416    async fn dispatch_llm_step_emits_context_breakdown_after_usage() {
3417        use crate::event::AgentEvent;
3418        use crate::llm::TokenUsage;
3419        use tokio::sync::mpsc;
3420
3421        let hooks = crate::hooks::HookRegistry::new();
3422        let mut core = make_test_core(vec![Message::user("hello world".to_string())], &hooks);
3423        // Seed static buckets so the breakdown has deterministic non-zero values.
3424        core.static_breakdown = StaticBreakdownCache {
3425            system_prompt: 100,
3426            rules: 50,
3427            skills: 25,
3428            subagents: 10,
3429            tools: 15,
3430            mcp_dynamic: 0,
3431        };
3432        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3433        core.events = Some(tx);
3434
3435        // The `Usage` reading: provider reports 1000 prompt tokens with
3436        // no cache split (OpenAI shape). Anthropic-style reporting would
3437        // put the cache split separately; both flows feed the breakdown
3438        // the same `max(input_tokens, cache_sum)`.
3439        let usage = TokenUsage {
3440            prompt_tokens: 1000,
3441            completion_tokens: 50,
3442            total_tokens: 1050,
3443            cache_hit_tokens: 0,
3444            cache_miss_tokens: 0,
3445            reasoning_tokens: 0,
3446        };
3447        core.emit_breakdown(7, &usage);
3448
3449        // Drain the channel and find the ContextBreakdown event.
3450        let event = rx
3451            .try_recv()
3452            .expect("ContextBreakdown event must be emitted");
3453        match event {
3454            AgentEvent::ContextBreakdown { breakdown, step } => {
3455                assert_eq!(step, 7, "step must be forwarded verbatim");
3456                let local = breakdown.local_sum();
3457                let expected_overhead = 1000u32.saturating_sub(local);
3458                assert_eq!(breakdown.overhead, expected_overhead);
3459                assert!(
3460                    breakdown.total() >= 1000,
3461                    "total must be >= provider_total; got {}",
3462                    breakdown.total()
3463                );
3464            }
3465            other => panic!("expected ContextBreakdown event, got {other:?}"),
3466        }
3467    }
3468
3469    /// `run_inner` emits `AgentEvent::ContextBreakdown` once per
3470    /// LLM-calling step (i.e. exactly once on the single-step happy
3471    /// path). The breakdown must come AFTER `Usage` so consumers
3472    /// observe the provider truth first.
3473    #[tokio::test]
3474    async fn run_inner_emits_context_breakdown_once_per_llm_step() {
3475        use crate::agent::FinishReason;
3476        use crate::event::AgentEvent;
3477        use crate::llm::{Completion, TokenUsage};
3478        use tokio::sync::mpsc;
3479
3480        let hooks = crate::hooks::HookRegistry::new();
3481        let provider = Arc::new(crate::llm::MockProvider::new(vec![Completion {
3482            content: "done".to_string(),
3483            tool_calls: vec![],
3484            finish_reason: Some("stop".to_string()),
3485            usage: Some(TokenUsage {
3486                prompt_tokens: 1000,
3487                completion_tokens: 50,
3488                total_tokens: 1050,
3489                cache_hit_tokens: 0,
3490                cache_miss_tokens: 0,
3491                reasoning_tokens: 0,
3492            }),
3493            reasoning_content: None,
3494        }]));
3495        let messages = vec![Message::user("hi".to_string())];
3496        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3497        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
3498        core.events = Some(tx);
3499
3500        let outcome = core.run_inner().await.expect("run_inner must not error");
3501        assert_eq!(outcome.finish_reason, FinishReason::NoMoreToolCalls);
3502
3503        // Drain events. Find the index of Usage and the index of
3504        // ContextBreakdown; the latter must come strictly after.
3505        let mut events = Vec::new();
3506        while let Ok(e) = rx.try_recv() {
3507            events.push(e);
3508        }
3509        let usage_idx = events
3510            .iter()
3511            .position(|e| matches!(e, AgentEvent::Usage { .. }))
3512            .expect("a Usage event must have been emitted");
3513        let breakdown_idx = events
3514            .iter()
3515            .position(|e| matches!(e, AgentEvent::ContextBreakdown { .. }))
3516            .expect("a ContextBreakdown event must have been emitted");
3517        assert!(
3518            breakdown_idx > usage_idx,
3519            "ContextBreakdown (idx={breakdown_idx}) must come AFTER Usage (idx={usage_idx})"
3520        );
3521    }
3522
3523    /// `run_inner` does NOT emit `ContextBreakdown` on a step that
3524    /// never calls the LLM (e.g. an immediate `TranscriptLimit` finish).
3525    #[tokio::test]
3526    async fn run_inner_skips_context_breakdown_on_no_llm_step() {
3527        use crate::agent::FinishReason;
3528        use crate::event::AgentEvent;
3529        use tokio::sync::mpsc;
3530
3531        let hooks = crate::hooks::HookRegistry::new();
3532        let provider = Arc::new(crate::llm::MockProvider::new(vec![]));
3533        let messages = vec![Message::system("hello".to_string())];
3534        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3535        let mut core = make_run_core_for_inner(messages, &hooks, provider, 1);
3536        core.events = Some(tx);
3537        // 5 chars > limit 3 → enforce_transcript_budget fires BEFORE the
3538        // first LLM call, so there should be no Usage / ContextBreakdown.
3539        core.max_transcript_chars = Some(3);
3540
3541        let outcome = core.run_inner().await.expect("run_inner must not error");
3542        assert!(
3543            matches!(outcome.finish_reason, FinishReason::TranscriptLimit { .. }),
3544            "expected TranscriptLimit finish; got {:?}",
3545            outcome.finish_reason
3546        );
3547
3548        let mut events = Vec::new();
3549        while let Ok(e) = rx.try_recv() {
3550            events.push(e);
3551        }
3552        assert!(
3553            !events
3554                .iter()
3555                .any(|e| matches!(e, AgentEvent::ContextBreakdown { .. })),
3556            "no ContextBreakdown must be emitted on a no-LLM step; got: {events:?}"
3557        );
3558        assert!(
3559            !events.iter().any(|e| matches!(e, AgentEvent::Usage { .. })),
3560            "no Usage must be emitted on a no-LLM step; got: {events:?}"
3561        );
3562    }
3563
3564    /// Static breakdown buckets must be cached once at `RunCore`
3565    /// construction and reused every step — the conversation bucket
3566    /// alone grows between steps. Verified by running two steps and
3567    /// comparing the breakdowns.
3568    ///
3569    /// Step 1: LLM returns a tool call (forces a second step).
3570    /// Step 2: LLM returns a final reply (loop exits).
3571    #[tokio::test]
3572    async fn static_buckets_dont_change_across_steps_conversation_grows() {
3573        use crate::event::AgentEvent;
3574        use crate::llm::{Completion, TokenUsage, ToolCall, ToolSpec};
3575        use crate::tools::{Tool, ToolRegistry};
3576        use async_trait::async_trait;
3577        use serde_json::{json, Value};
3578        use tokio::sync::mpsc;
3579
3580        /// Minimal tool that always succeeds so step 1's tool call is
3581        /// dispatched cleanly and the loop proceeds to step 2.
3582        struct Adder;
3583        #[async_trait]
3584        impl Tool for Adder {
3585            fn spec(&self) -> ToolSpec {
3586                ToolSpec {
3587                    name: "add".to_string(),
3588                    description: "add two numbers".to_string(),
3589                    parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
3590                }
3591            }
3592            async fn execute(&self, _args: Value) -> crate::error::Result<String> {
3593                Ok("7".to_string())
3594            }
3595        }
3596
3597        let hooks = crate::hooks::HookRegistry::new();
3598        let registry = ToolRegistry::default().register(Arc::new(Adder));
3599        let provider = Arc::new(crate::llm::MockProvider::new(vec![
3600            // Step 1: tool call → forces step 2.
3601            Completion {
3602                content: "calculating…".to_string(),
3603                tool_calls: vec![ToolCall {
3604                    id: "tc1".to_string(),
3605                    name: "add".to_string(),
3606                    arguments: json!({"a": 3, "b": 4}),
3607                }],
3608                finish_reason: Some("tool_calls".to_string()),
3609                usage: Some(TokenUsage {
3610                    prompt_tokens: 1000,
3611                    completion_tokens: 30,
3612                    total_tokens: 1030,
3613                    cache_hit_tokens: 0,
3614                    cache_miss_tokens: 0,
3615                    reasoning_tokens: 0,
3616                }),
3617                reasoning_content: None,
3618            },
3619            // Step 2: final reply → loop exits.
3620            Completion {
3621                content: "the answer is 7".to_string(),
3622                tool_calls: vec![],
3623                finish_reason: Some("stop".to_string()),
3624                usage: Some(TokenUsage {
3625                    prompt_tokens: 1200, // provider thinks transcript grew
3626                    completion_tokens: 50,
3627                    total_tokens: 1250,
3628                    cache_hit_tokens: 0,
3629                    cache_miss_tokens: 0,
3630                    reasoning_tokens: 0,
3631                }),
3632                reasoning_content: None,
3633            },
3634        ]));
3635        let messages = vec![Message::user("hi".to_string())];
3636        let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
3637        let mut core = make_run_core_for_inner(messages, &hooks, provider, 5);
3638        core.tools = Arc::new(registry);
3639        core.events = Some(tx);
3640        // Seed static buckets so we can verify they're identical across
3641        // both steps.
3642        core.static_breakdown = StaticBreakdownCache {
3643            system_prompt: 100,
3644            rules: 50,
3645            skills: 25,
3646            subagents: 10,
3647            tools: 15,
3648            mcp_dynamic: 0,
3649        };
3650        core.prompt_segments = Some(crate::system_prompt::PromptSegments {
3651            rules: "RR".into(),
3652            system_prompt: "SS".into(),
3653            skills: String::new(),
3654            subagents: String::new(),
3655        });
3656
3657        let outcome = core.run_inner().await.expect("run_inner must not error");
3658        drop(outcome); // satisfy unused-var lint
3659
3660        // Collect both ContextBreakdown events.
3661        let mut breakdowns = Vec::new();
3662        while let Ok(e) = rx.try_recv() {
3663            if let AgentEvent::ContextBreakdown { breakdown, .. } = e {
3664                breakdowns.push(breakdown);
3665            }
3666        }
3667        assert_eq!(
3668            breakdowns.len(),
3669            2,
3670            "two ContextBreakdown events must have been emitted; got {breakdowns:?}"
3671        );
3672
3673        // Static buckets must be identical across both steps.
3674        assert_eq!(breakdowns[0].system_prompt, breakdowns[1].system_prompt);
3675        assert_eq!(breakdowns[0].rules, breakdowns[1].rules);
3676        assert_eq!(breakdowns[0].skills, breakdowns[1].skills);
3677        assert_eq!(breakdowns[0].subagents, breakdowns[1].subagents);
3678        assert_eq!(breakdowns[0].tools, breakdowns[1].tools);
3679        assert_eq!(breakdowns[0].mcp_dynamic, breakdowns[1].mcp_dynamic);
3680        assert_eq!(
3681            breakdowns[0].system_prompt, 100,
3682            "static_breakdown cache must be honoured"
3683        );
3684
3685        // Conversation bucket must grow between step 1 and step 2
3686        // (the tool result + assistant reply add to the transcript).
3687        assert!(
3688            breakdowns[1].conversation > breakdowns[0].conversation,
3689            "conversation must grow between steps: step1={} step2={}",
3690            breakdowns[0].conversation,
3691            breakdowns[1].conversation
3692        );
3693
3694        // Overhead must be positive (provider total > local sum): the
3695        // provider reports 1000 / 1200 prompt tokens, which includes
3696        // wrapping the local estimate doesn't capture.
3697        assert!(breakdowns[0].overhead > 0);
3698        assert!(breakdowns[1].overhead > 0);
3699    }
3700
3701    /// `StaticBreakdownCache::build` must partition specs into eager
3702    /// vs deferred via `ToolRegistry::is_deferred_spec`. We can't easily
3703    /// register a deferred spec on a `default()` registry, so the
3704    /// zero-spec case must produce zero tokens and the cache must not
3705    /// crash.
3706    #[test]
3707    fn static_breakdown_cache_build_zero_specs_yields_zero_tokens() {
3708        let segments = crate::system_prompt::PromptSegments::default();
3709        let registry = crate::tools::ToolRegistry::default();
3710        let cache = StaticBreakdownCache::build(&segments, &[], &registry);
3711        assert_eq!(cache.system_prompt, 0);
3712        assert_eq!(cache.rules, 0);
3713        assert_eq!(cache.skills, 0);
3714        assert_eq!(cache.subagents, 0);
3715        assert_eq!(cache.tools, 0);
3716        assert_eq!(cache.mcp_dynamic, 0);
3717    }
3718
3719    /// `StaticBreakdownCache::build` must tokenise each segment's
3720    /// length using chars/4 ceil, identical to `llm::estimate_tokens`.
3721    #[test]
3722    fn static_breakdown_cache_build_tokenises_segments() {
3723        // 9-char segment → ceil(9/4) = 3 tokens. (9/4 = 2.25, ceil = 3.)
3724        let segments = crate::system_prompt::PromptSegments {
3725            rules: "012345678".into(),          // 9 chars
3726            system_prompt: "1234567890".into(), // 10 chars → 3 tokens (10/4=2.5, ceil=3)
3727            skills: "abcde".into(),             // 5 chars → 2 tokens (5/4=1.25, ceil=2)
3728            subagents: "abcdefgh".into(),       // 8 chars → 2 tokens (8/4=2.0, ceil=2)
3729        };
3730        let registry = crate::tools::ToolRegistry::default();
3731        let cache = StaticBreakdownCache::build(&segments, &[], &registry);
3732        assert_eq!(cache.rules, 3, "9 chars / 4 = 2.25 → ceil = 3");
3733        assert_eq!(cache.system_prompt, 3, "10 chars / 4 = 2.5 → ceil = 3");
3734        assert_eq!(cache.skills, 2, "5 chars / 4 = 1.25 → ceil = 2");
3735        assert_eq!(cache.subagents, 2, "8 chars / 4 = 2.0 → ceil = 2");
3736    }
3737
3738    #[test]
3739    fn estimate_tokens_by_bytes_rounds_up_by_four_bytes() {
3740        // Kills `/ -> *` (63:32) and `> -> >=` / `<` / `==` (64:29) in
3741        // estimate_tokens_by_bytes: 0 bytes → 0 tokens, any non-zero bytes
3742        // → ceil(bytes / 4), and a tiny positive byte count must still be 1.
3743        assert_eq!(estimate_tokens_by_bytes(0), 0);
3744        assert_eq!(estimate_tokens_by_bytes(1), 1);
3745        assert_eq!(estimate_tokens_by_bytes(4), 1);
3746        assert_eq!(estimate_tokens_by_bytes(5), 2);
3747        assert_eq!(estimate_tokens_by_bytes(8), 2);
3748    }
3749
3750    #[test]
3751    fn enforce_transcript_budget_fires_when_chars_exactly_equal_limit() {
3752        // Kills `< -> <=` (672:18) in enforce_transcript_budget: when the
3753        // transcript is EXACTLY at the limit the budget must fire (Some);
3754        // the `<=` mutant would treat it as under-budget (None).
3755        use crate::agent::FinishReason;
3756        use crate::llm::TokenUsage;
3757        let hooks = crate::hooks::HookRegistry::new();
3758        // "hello" = exactly 5 chars, limit = 5.
3759        let mut core = make_test_core(vec![Message::user("hello".to_string())], &hooks);
3760        core.max_transcript_chars = Some(5);
3761        let usage = TokenUsage::default();
3762        let result = core.enforce_transcript_budget(0, &usage);
3763        assert!(
3764            result.is_some(),
3765            "chars (5) == limit (5) must return Some finish reason"
3766        );
3767        let (finish, _step) = result.unwrap();
3768        assert!(
3769            matches!(finish, FinishReason::TranscriptLimit { .. }),
3770            "finish reason must be TranscriptLimit; got {finish:?}"
3771        );
3772    }
3773
3774    #[test]
3775    fn check_wall_deadline_disabled_when_timeout_zero() {
3776        // Kills `== -> !=` (735:35) in check_wall_deadline: a timeout of 0
3777        // must disable the deadline entirely (None); the `!=` mutant would
3778        // fall through to the elapsed check and fire.
3779        use crate::llm::TokenUsage;
3780        let hooks = crate::hooks::HookRegistry::new();
3781        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3782        core.wall_timeout_secs = 0;
3783        core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
3784        let usage = TokenUsage::default();
3785        assert!(
3786            core.check_wall_deadline(0, &usage).is_none(),
3787            "timeout 0 must disable the wall-clock deadline"
3788        );
3789    }
3790
3791    #[test]
3792    fn check_wall_deadline_fires_after_timeout_elapsed() {
3793        // Kills `-> None` (735:9) and `< -> >` (739:30): with elapsed (5s)
3794        // strictly greater than the timeout (3s) the deadline must fire.
3795        use crate::agent::FinishReason;
3796        use crate::llm::TokenUsage;
3797        let hooks = crate::hooks::HookRegistry::new();
3798        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3799        core.wall_timeout_secs = 3;
3800        core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(5));
3801        let usage = TokenUsage::default();
3802        let result = core.check_wall_deadline(0, &usage);
3803        assert!(
3804            result.is_some(),
3805            "elapsed 5s > timeout 3s must fire the deadline"
3806        );
3807        let (finish, _step) = result.unwrap();
3808        assert!(
3809            matches!(finish, FinishReason::WallClockExceeded { .. }),
3810            "finish reason must be WallClockExceeded; got {finish:?}"
3811        );
3812    }
3813
3814    #[test]
3815    fn check_wall_deadline_fires_when_elapsed_equals_timeout() {
3816        // Kills `< -> <=` and `< -> ==` (739:30): when elapsed seconds EXACTLY
3817        // equal the timeout, `elapsed < timeout` is false → the deadline must
3818        // fire; `<=`/`==` would return None. `wall_start` is set 3s in the
3819        // past and the deadline checked immediately, so `elapsed().as_secs()`
3820        // is exactly 3 (a full second boundary is guaranteed until >1s passes
3821        // between construction and the check).
3822        use crate::llm::TokenUsage;
3823        let hooks = crate::hooks::HookRegistry::new();
3824        let mut core = make_test_core(vec![Message::user("hi".to_string())], &hooks);
3825        core.wall_timeout_secs = 3;
3826        core.wall_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
3827        let usage = TokenUsage::default();
3828        let result = core.check_wall_deadline(0, &usage);
3829        assert!(
3830            result.is_some(),
3831            "elapsed == timeout must fire the deadline"
3832        );
3833    }
3834}