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