Skip to main content

newt_core/agentic/
mod.rs

1//! The agentic loop — call model → execute tool calls → feed results back →
2//! repeat — extracted verbatim from `newt-tui` in Step 9.7 so the same
3//! battle-tested loop can serve both the TUI and the ACP worker (Step 9.8)
4//! without being written twice.
5//!
6//! `ChatCtx` stays a **concrete** type: there is exactly one loop
7//! implementation and no `InferenceBackend` trait (YAGNI per the roadmap —
8//! revisit only when a second concrete backend exists). The only seam is
9//! [`McpTools`], which breaks the `newt-core` ← `newt-mcp-client` dependency
10//! cycle; see `mcp.rs`.
11
12// pub(crate) since Step 18.5 (#247): the `Summarizing` memory provider
13// delegates to this same pipeline instead of keeping a duplicate one.
14// #727: read-only context-budget introspection (the `get_context_remaining`
15// tool) — a pure renderer the agentic loop feeds per-turn budget state into.
16mod budget;
17// #867: path-claim verification for the cap-exit summary (the file-name
18// sibling of the #717 phantom-tool-reach telemetry).
19mod claim_check;
20pub(crate) mod compress;
21mod crew_attest;
22mod crew_tool;
23mod display;
24mod git_tool;
25// Step 26.4 (#583): scratchpad structured-state — the `scratchpad` context feature.
26pub(crate) mod scratchpad;
27// Step 26.5 (#582): semantic repo-evidence retrieval (embedding RAG-for-code).
28pub(crate) mod semantic;
29// Step 26.6a (#585): experiential memory — the `experiential` context feature.
30pub(crate) mod experiential;
31// Step 26.6b (#586): scheduled per-step compiled view — the `scheduled` feature.
32pub(crate) mod scheduled;
33// Step 26.3 (#584): tool-output offloading — the `tool_offload` context feature.
34/// Drive an overseer-authored plan through a `CrewRunner` (#628 P2 execute side).
35pub(crate) mod plan_exec;
36pub(crate) mod spill;
37/// Recover tool calls a weak model emitted in CONTENT instead of the native
38/// `tool_calls` field (the #1 weak-model failure — see the module docs).
39pub(crate) mod tool_recovery;
40// Issue #308 — the cowork foundation: a non-blocking turn driver around
41// `chat_complete` (driver), a renderer-agnostic transcript render (transcript),
42// and the redaction-gated ShellObservation seam (observation). All additive;
43// they wrap/precede `chat_complete` and never touch its internals.
44mod driver;
45// Step 25.1 (#568): Markdown → ANSI rendering of assistant output. Behind the
46// default `markdown` feature; a passthrough shim takes its place under
47// --no-default-features so the headless wyvern strip carries no markdown deps.
48#[cfg(feature = "markdown")]
49mod markdown;
50#[cfg(not(feature = "markdown"))]
51mod markdown {
52    //! Passthrough shim used when the `markdown` feature is disabled (the
53    //! headless wyvern strip). Keeps `render_markdown` / `RenderOpts` /
54    //! `MarkdownStreamWriter` in the public API with zero markdown
55    //! dependencies; output is the source verbatim — identical to color-off
56    //! behavior in the full renderer.
57    use std::io::{self, Write};
58
59    #[derive(Debug, Clone, Copy)]
60    pub struct RenderOpts {
61        pub color: bool,
62        pub cols: usize,
63    }
64    pub fn render_markdown(src: &str, _opts: RenderOpts) -> String {
65        src.to_string()
66    }
67
68    /// Raw passthrough writer — bytes through unchanged, with a trailing newline
69    /// at `finish` if the stream didn't end with one (matching the raw token
70    /// path's closing `println!`).
71    pub struct MarkdownStreamWriter<W: Write> {
72        out: W,
73        wrote: bool,
74        ended_nl: bool,
75    }
76    impl<W: Write> MarkdownStreamWriter<W> {
77        pub fn new(out: W, _opts: RenderOpts) -> Self {
78            Self {
79                out,
80                wrote: false,
81                ended_nl: true,
82            }
83        }
84        pub fn push(&mut self, delta: &str) -> io::Result<()> {
85            if let Some(&last) = delta.as_bytes().last() {
86                self.wrote = true;
87                self.ended_nl = last == b'\n';
88            }
89            self.out.write_all(delta.as_bytes())
90        }
91        pub fn finish(&mut self) -> io::Result<()> {
92            if self.wrote && !self.ended_nl {
93                self.out.write_all(b"\n")?;
94            }
95            self.out.flush()
96        }
97    }
98}
99mod mcp;
100mod memory_fetch;
101mod note_sink;
102mod observation;
103mod permissions;
104mod recall;
105// #714: the `resume_context` tool — a self-scoped read of THIS conversation's
106// own pre-interrupt work (the affordance `recall` structurally cannot be).
107mod resume;
108// facade P4 (#780): hidden tool-call routing — promote the model's read-only
109// shell reaches (`cat`/`ls`/`find` + read-only `git`) to a silent rewrite onto
110// the governed built-ins, gate the rest. The route/gate split is pure DATA.
111mod routing;
112// #725: the `tool_search` discovery tool — find a real tool by intent instead
113// of fabricating a foreign name (the structural complement to the #716 alias
114// seam + #717 phantom telemetry).
115mod tool_search;
116mod tools;
117mod transcript;
118mod trim;
119mod warmup;
120
121pub use compress::{
122    compress_user_initiated, CompressCounters, CompressState, ManualCompressOutcome, SummarizeFn,
123    SummarizeFuture, Summarizer, SUMMARY_END_MARKER, SUMMARY_PREFIX,
124};
125pub use crew_attest::{crew_authz, crew_step_up_policy, CrewAuthz, Presence};
126pub use crew_tool::{compose_roster_tool_definition, crew_tool_definition, CrewRunner};
127pub use display::{
128    fmt_token_gauge, fmt_tokens_compact, gauge_level, print_harness_notice, print_list_item,
129    print_newt, GaugeLevel, NEWT_ORANGE_CT,
130};
131pub use driver::{
132    TurnDriver, TurnDriverConfig, TurnDriverError, TurnOutcome, TurnStatus,
133    VISIBLE_TRANSCRIPT_ROLES,
134};
135pub use experiential::{
136    experience_block, ExperienceStore, SessionExperienceStore, EXPERIENCE_TOP_K,
137};
138pub use git_tool::{git_tool_definition, GitTool};
139pub use markdown::{render_markdown, MarkdownStreamWriter, RenderOpts};
140pub use mcp::{McpTools, NoMcp};
141pub use plan_exec::{run_plan, run_plan_with_reground, NoReground, PlanRun, Reground};
142pub use scheduled::{
143    plan_block, plan_reseat_pointer, PlanSnapshot, SessionStepLedger, Step, StepLedger, StepStatus,
144};
145pub use scratchpad::{scratchpad_state_block, ScratchpadStore, SessionScratchpadStore};
146pub use semantic::{
147    chunk_source, code_evidence_block, code_search_tool_definition, cosine, gather_code_files,
148    index_files, retrieve_evidence, CodeChunk, CodeSearch, Embedder, EmbeddingsClient,
149    SemanticIndex, SessionSemanticIndex,
150};
151pub use spill::{SessionSpillStore, SpillStore};
152
153/// Align GFM table pipes in Markdown **source** (Step 25.5, #568) — plain text,
154/// no ANSI. The headless **wyvern** tier keeps Markdown as source (no rendering),
155/// so this tidies ragged pipe tables for transcripts other agents read. It is
156/// **independent of the `markdown` feature** (wyvern builds `--no-default-features`)
157/// and gated on the optional `markdown-table-formatter` feature: identity unless
158/// enabled, so it never pulls comrak/wasm-bindgen into a default build.
159#[cfg(feature = "markdown-table-formatter")]
160pub fn tidy_markdown_tables(src: &str) -> String {
161    markdown_table_formatter::format_tables(src)
162}
163
164/// Identity passthrough when the `markdown-table-formatter` feature is off.
165#[cfg(not(feature = "markdown-table-formatter"))]
166pub fn tidy_markdown_tables(src: &str) -> String {
167    src.to_string()
168}
169
170#[cfg(test)]
171mod tidy_tables_tests {
172    #[cfg(not(feature = "markdown-table-formatter"))]
173    #[test]
174    fn identity_without_the_feature() {
175        // The default build (and the wyvern strip without the opt-in) leaves the
176        // source untouched.
177        let ragged = "| a | bb |\n|---|---|\n| ccc | d |";
178        assert_eq!(super::tidy_markdown_tables(ragged), ragged);
179    }
180
181    #[cfg(feature = "markdown-table-formatter")]
182    #[test]
183    fn aligns_pipes_with_the_feature() {
184        let ragged = "| a | bb |\n| --- | --- |\n| ccc | d |\n";
185        let tidy = super::tidy_markdown_tables(ragged);
186        assert_ne!(tidy, ragged, "the table should be reformatted");
187        assert!(tidy.contains("ccc"), "content preserved");
188        // Every pipe-bearing row lines its pipes up at the same columns.
189        let pipe_cols = |s: &str| {
190            s.char_indices()
191                .filter(|(_, c)| *c == '|')
192                .map(|(i, _)| i)
193                .collect::<Vec<_>>()
194        };
195        let rows: Vec<&str> = tidy.lines().filter(|l| l.contains('|')).collect();
196        let first = pipe_cols(rows[0]);
197        for r in &rows {
198            assert_eq!(pipe_cols(r), first, "pipes aligned across rows: {r:?}");
199        }
200    }
201}
202pub use budget::get_context_remaining_tool_definition;
203pub use memory_fetch::{
204    memory_fetch_tool_definition, MemAddr, MemPayload, MemorySource, StoreMemorySource,
205};
206pub use note_sink::{save_note_tool_definition, NoteNudge, NoteSink};
207pub use observation::{ShellObservation, SHELL_OBSERVATION_PREFIX};
208pub use permissions::{
209    append_denial, load_denials, widen_caveats, DenialKind, PermissionDecision, PermissionGate,
210    PermissionRecord, PermissionRequest, PersistentDenial,
211};
212pub use recall::{recall_tool_definition, RecallSource, StoreRecallSource};
213pub use resume::resume_context_tool_definition;
214pub use tools::{
215    execute_tool, execute_tool_with_offload, full_access_requested, ocap_disabled,
216    set_max_output_tokens, set_output_head_tokens, tool_definitions, venv_cmd_prefix,
217};
218pub use transcript::{
219    transcript_lines, transcript_lines_styled, TranscriptLine, TranscriptRole, TranscriptStyle,
220};
221pub use trim::trim_for_summary;
222pub use warmup::warmup_if_cold;
223
224use crate::retry::{with_backoff_notify, RetryPolicy};
225use compress::{compress, compression_trigger, CompressAction, CompressRequest};
226use crossterm::{
227    execute,
228    style::{Color as CtColor, Print, ResetColor, SetForegroundColor},
229};
230use display::{
231    emit_compression_notice, emit_overflow_notice, print_debug, print_retry_indicator, print_trace,
232};
233use std::io::{self, Write as _};
234use tools::{is_hallucination, merged_tool_definitions};
235use trim::{
236    estimate_request_tokens, estimate_tokens, estimate_value_tokens, merge_round_usage,
237    ollama_usage, openai_usage, PromptTracker,
238};
239
240/// Retry policy for TUI inference calls: more patient than the hosted-API
241/// default because local DGX nodes can drop for 30–60 s under load.
242/// Total resilience window: ~90 s (2+4+8+16+30+30 s between attempts).
243/// All thresholds are overridable via the standard `NEWT_HTTP_*` env vars.
244fn tui_retry_policy() -> RetryPolicy {
245    RetryPolicy::for_local_inference()
246}
247
248/// Hook recovering a hard context-window 400:
249/// `(error, model, today) → new input-token cap`. See [`ChatCtx::recover_cw_400`].
250pub type RecoverCw400 = fn(&anyhow::Error, &str, &str) -> Option<u32>;
251
252/// One per-round capability observation, reported through
253/// [`ChatCtx::on_round_usage`] at the moment it is observed (Phase 20,
254/// `docs/design/model-self-tuning.md` §2.2 — the `recover_cw_400` pattern,
255/// generalized to the success direction). Evidence must not wait for a turn
256/// epilogue an error can skip: the motivating failure discarded a backend-
257/// accepted 8,734-token prompt because the turn later ended in `Err`.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum RoundObservation {
260    /// Backend evaluated `prompt_tokens` and the round produced a usable
261    /// response (tool calls or non-empty content). `estimated_tokens` is the
262    /// loop's chars/4 estimate of the same request, for calibration.
263    /// Quality-gated AND truncation-gated at the emission sites: never
264    /// emitted when the prompt was within 5% of the request's `num_ctx`
265    /// (Ollama may have silently dropped the head of the prompt).
266    Accepted {
267        prompt_tokens: u32,
268        estimated_tokens: usize,
269    },
270    /// Persistent empty responses at `prompt_tokens` after retries (the
271    /// 85%-of-safe-context silent-overflow exit).
272    SuspectedOverflow { prompt_tokens: u32 },
273    /// Response carried only non-content fields (thinking/reasoning) with
274    /// empty content.
275    ThinkingOnly,
276}
277
278/// Input-token ceiling implied by the `num_ctx` THIS request will carry
279/// (issue #282, the B6 hole): `num_ctx` caps the backend's whole KV window —
280/// input *plus* reply — so the input budget is 80 % of it, the same reply/
281/// estimate headroom the probe's budget math already reserves against a hard
282/// window (`safe_context` bootstraps at 80 % of the declared window; a cw-400
283/// sets `max_ok_input` to 80 % of the parsed limit). `None` (model-default
284/// window) or zero contributes nothing — the zero-is-disabled contract (F3),
285/// never a compress-to-zero.
286fn num_ctx_input_ceiling(num_ctx: Option<u32>) -> Option<usize> {
287    num_ctx.map(|c| (c as usize) * 80 / 100).filter(|&c| c > 0)
288}
289
290/// Initial pre-send budget for one turn (issue #282; Phase 20 semantics per
291/// `docs/design/model-self-tuning.md` §2.1): the empirically-cached figure is
292/// `max(max_ok_input, safe_context)` composed, via `min`, with the
293/// [`num_ctx_input_ceiling`] of the `num_ctx` the loop is about to send.
294///
295/// `max_ok_input` is a high-water mark of PROVEN-good input — a floor, not a
296/// ceiling. Preferring it over `safe_context` (the pre-Phase-20 contract)
297/// turned "largest prompt seen so far" into a cap, which is the motivating
298/// failure: a stale 6,068 ratchet refused sends the backend was accepting at
299/// 8,734 tokens. `max()` lets whichever of proven-good and believed-safe is
300/// larger drive the budget. The cw-400 path already reins `safe_context`
301/// down to its authoritative cap, so after a hard 400 `max()` still lands on
302/// the authoritative number.
303///
304/// The `num_ctx` ceiling composition is unchanged: before #282 the budget
305/// was the cached numbers alone — unset on a fresh capability cache until
306/// the turn ENDS, so the first turn of a session had no effective ceiling
307/// and a 41k-token request sailed into a forced 4,096 window with zero
308/// compression events (the measured B6 failure: 8/10 silently wrong). The
309/// ceiling is a real token budget: when it fires the trigger, `hard_budget`
310/// semantics apply (consults + feeds anti-thrash).
311fn initial_send_budget(
312    max_ok_input: Option<u32>,
313    safe_context: Option<u32>,
314    num_ctx: Option<u32>,
315) -> Option<usize> {
316    let cached = match (max_ok_input, safe_context) {
317        (Some(m), Some(s)) => Some(m.max(s) as usize),
318        (m, s) => m.or(s).map(|c| c as usize),
319    };
320    match (cached, num_ctx_input_ceiling(num_ctx)) {
321        (Some(budget), Some(ceiling)) => Some(budget.min(ceiling)),
322        (budget, ceiling) => budget.or(ceiling),
323    }
324}
325
326/// Convert a chars/4 estimate into real (backend-reported) token space using
327/// the learned per-model `estimate_ratio` (Phase 20,
328/// `docs/design/model-self-tuning.md` §2.3). Ceiling: estimates must err on
329/// the side of counting, never undercounting — the 18.1 rule.
330fn calibrate_up(est: usize, ratio: f32) -> usize {
331    (est as f32 * ratio).ceil() as usize
332}
333
334/// Convert a real-token budget into estimate (chars/4) space — the currency
335/// the compression pipeline measures and reclaims in (Phase 20 §2.3).
336/// Floor: a tighter target is safer than a looser one.
337fn calibrate_down(real: usize, ratio: f32) -> usize {
338    (real as f32 / ratio).floor() as usize
339}
340
341/// Sanitize a per-model `estimate_ratio` for one turn (Phase 20 §2.3): only
342/// finite values inside the learning clamp [0.5, 3.0] are trusted; anything
343/// else (absent, NaN, a corrupted cache entry) degrades to 1.0 — the
344/// identity, i.e. exactly the pre-calibration behavior.
345fn sanitize_estimate_ratio(estimate_ratio: Option<f32>) -> f32 {
346    estimate_ratio
347        .filter(|r| r.is_finite() && (0.5..=3.0).contains(r))
348        .unwrap_or(1.0)
349}
350
351/// Report one quality-gated [`RoundObservation::Accepted`] (Phase 20 §2.2).
352/// Called only from usable-output control paths (tool calls or non-empty
353/// content — the quality gate); skips when the prompt was truncation-suspect
354/// (≥95% of the request's `num_ctx`, where Ollama may have silently dropped
355/// the head) or when the backend reported no usage for the round.
356fn emit_accepted(
357    hook: &mut Option<&mut dyn FnMut(RoundObservation)>,
358    round_usage: Option<crate::TokenUsage>,
359    truncation_suspect: bool,
360    estimated_tokens: usize,
361) {
362    if truncation_suspect {
363        return;
364    }
365    if let (Some(hook), Some(u)) = (hook.as_deref_mut(), round_usage) {
366        hook(RoundObservation::Accepted {
367            prompt_tokens: u.input_tokens,
368            estimated_tokens,
369        });
370    }
371}
372
373// Unit tests for the #282 budget wiring: the `num_ctx` a request will carry
374// must participate in the pre-send budget — composing with the cached
375// capability numbers via `min`, vanishing when absent, and never turning a
376// zero/absent window into a compress-to-zero (F3).
377#[cfg(test)]
378mod send_budget_tests {
379    use super::compress::compression_trigger;
380    use super::{initial_send_budget, num_ctx_input_ceiling};
381
382    /// THE B6 first-turn hole: a fresh capability cache (no `max_ok_input`,
383    /// no `safe_context`) used to mean NO budget at all even though the
384    /// request itself carried `options.num_ctx = 4096`. The ceiling must now
385    /// arm the trigger on turn 1 — as a HARD budget (anti-thrash semantics).
386    #[test]
387    fn first_turn_fresh_cache_trigger_sees_the_num_ctx_ceiling() {
388        let budget = initial_send_budget(None, None, Some(4096));
389        assert_eq!(budget, Some(3276), "80% of 4096 — reply headroom reserved");
390        // The measured B6 shape: ~41k estimated tokens, 3 messages, no
391        // count/token thresholds in reach — pre-fix this returned None and
392        // the request sailed into the 4k window with zero events.
393        let trigger = compression_trigger(3, 41_355, 39_900, 40, None, budget, 1_432)
394            .expect("the ceiling must fire the trigger on the first turn");
395        assert!(trigger.hard_budget, "a real token budget, not a soft halve");
396        assert_eq!(
397            trigger.budget,
398            3_276 - 1_432,
399            "budget lands in message space: ceiling minus tool-schema tokens"
400        );
401        assert_eq!(trigger.max_messages, None, "no count firing here");
402    }
403
404    /// Absent `num_ctx` → exactly the cached-numbers budget (no ceiling).
405    /// CONTRACT CHANGED in Phase 20 (docs/design/model-self-tuning.md §2.1):
406    /// the cached figure is now `max(max_ok_input, safe_context)` — the
407    /// high-water mark is a floor of proven-good, not a ceiling, so it must
408    /// never pull the budget BELOW the believed-safe window.
409    #[test]
410    fn absent_num_ctx_leaves_the_budget_unchanged() {
411        assert_eq!(initial_send_budget(None, None, None), None);
412        assert_eq!(initial_send_budget(Some(2_000), None, None), Some(2_000));
413        assert_eq!(initial_send_budget(None, Some(5_000), None), Some(5_000));
414        assert_eq!(
415            initial_send_budget(Some(2_000), Some(5_000), None),
416            Some(5_000),
417            "an HWM below safe_context is a floor, not a cap — safe_context wins"
418        );
419        // And with no budget at all, the trigger stays silent regardless of size.
420        assert_eq!(
421            compression_trigger(3, 41_355, 39_900, 40, None, None, 1_432),
422            None
423        );
424    }
425
426    /// Phase 20 §2.1 — the max(proven, believed) contract, all three shapes:
427    /// HWM below the claim, HWM above the claim (proven beyond it), and the
428    /// post-cw-400 shape where `safe_context` was reined to the authoritative
429    /// cap so `max()` still lands on the authoritative number.
430    #[test]
431    fn cached_budget_is_max_of_proven_and_believed() {
432        // The motivating failure: max_ok_input ratcheted to 6,068 (largest
433        // prompt SEEN) while safe_context believed 80% of a 32k window safe.
434        // Pre-fix the 6,068 won and refused sends the backend accepted.
435        assert_eq!(
436            initial_send_budget(Some(6_068), Some(26_214), None),
437            Some(26_214),
438            "HWM below safe_context → safe_context"
439        );
440        // Proven beyond the claim: an accepted 8,734-token prompt outranks a
441        // conservative claim-derived window.
442        assert_eq!(
443            initial_send_budget(Some(8_734), Some(6_553), None),
444            Some(8_734),
445            "HWM above safe_context (proven beyond the claim) → HWM"
446        );
447        // cw-400-reined shape (#223): the 400 set max_ok_input to 80% of the
448        // endpoint's reported hard limit (authoritative, may be HIGH) and
449        // reined safe_context down to equal-or-lower — max() must land on
450        // the authoritative cap, not regress to the VRAM-capped figure.
451        assert_eq!(
452            initial_send_budget(Some(800_000), Some(64_000), None),
453            Some(800_000),
454            "post-cw-400: max_ok_input is the authoritative cap"
455        );
456        assert_eq!(
457            initial_send_budget(Some(800_000), Some(800_000), None),
458            Some(800_000)
459        );
460    }
461
462    /// Phase 20 §2.3 — the calibration converters: ratio 1.0 is the identity,
463    /// estimate→real rounds UP (must-err-on-counting, the 18.1 rule),
464    /// real→estimate rounds DOWN (a tighter compression target is safer).
465    #[test]
466    fn calibration_helpers_round_in_the_safe_direction() {
467        use super::{calibrate_down, calibrate_up, sanitize_estimate_ratio};
468        // Identity at 1.0 — the no-calibration baseline is exact.
469        assert_eq!(calibrate_up(6_068, 1.0), 6_068);
470        assert_eq!(calibrate_down(6_068, 1.0), 6_068);
471        // The measured nemotron3 shape: chars/4 undercounts ~30% (×1.3).
472        assert_eq!(calibrate_up(1_000, 1.3), 1_300);
473        assert_eq!(calibrate_down(1_000, 1.3), 769, "floor, never round up");
474        // Fractional results: up ceils, down floors.
475        assert_eq!(calibrate_up(3, 1.5), 5, "4.5 ceils to 5");
476        assert_eq!(calibrate_down(3, 2.0), 1, "1.5 floors to 1");
477        // Sanitizer: absent / NaN / out-of-clamp all degrade to identity.
478        assert_eq!(sanitize_estimate_ratio(None), 1.0);
479        assert_eq!(sanitize_estimate_ratio(Some(f32::NAN)), 1.0);
480        assert_eq!(sanitize_estimate_ratio(Some(0.1)), 1.0);
481        assert_eq!(sanitize_estimate_ratio(Some(5.0)), 1.0);
482        assert_eq!(sanitize_estimate_ratio(Some(1.29)), 1.29);
483        assert_eq!(sanitize_estimate_ratio(Some(0.5)), 0.5, "clamp inclusive");
484        assert_eq!(sanitize_estimate_ratio(Some(3.0)), 3.0, "clamp inclusive");
485    }
486
487    /// Phase 20 §2.3 — currency composition at the trigger boundary: a
488    /// real-token send budget minus calibrated-up tool tokens, fired through
489    /// the trigger, then calibrated DOWN into the pipeline's chars/4 space,
490    /// must equal converting each leg separately (the e2e wiring in both
491    /// loops relies on this composition).
492    #[test]
493    fn calibration_composes_across_the_trigger_boundary() {
494        use super::{calibrate_down, calibrate_up};
495        let cal = 1.3_f32;
496        let send_budget = 8_734_usize; // real tokens
497        let tool_tokens_est = 1_000_usize; // chars/4 estimate
498        let tool_tokens_real = calibrate_up(tool_tokens_est, cal); // 1,300
499        let current_real = calibrate_up(9_000, cal); // estimate → real
500        let trigger = compression_trigger(
501            3,
502            current_real,
503            9_000,
504            40,
505            None,
506            Some(send_budget),
507            tool_tokens_real,
508        )
509        .expect("over-budget context fires the guard");
510        assert!(trigger.hard_budget);
511        // trigger.budget is real space (send budget minus real tool tokens);
512        // the pipeline target converts it back to estimate space.
513        assert_eq!(trigger.budget, send_budget - tool_tokens_real);
514        let pipeline_budget = calibrate_down(trigger.budget, cal);
515        assert_eq!(pipeline_budget, calibrate_down(8_734 - 1_300, cal));
516        assert!(
517            pipeline_budget < trigger.budget,
518            "ratio > 1: the estimate-space target is tighter than the real one"
519        );
520    }
521
522    /// The ceiling composes with existing budgets via `min` — whichever is
523    /// tighter wins, in both directions.
524    #[test]
525    fn ceiling_composes_with_cached_budgets_via_min() {
526        // Cached cap tighter than the ceiling: cached wins (mid-loop B5
527        // behavior is untouched by #282).
528        assert_eq!(
529            initial_send_budget(Some(2_135), None, Some(4_096)),
530            Some(2_135)
531        );
532        // Ceiling tighter than the cached cap: the B6 shape — bootstrap
533        // safe_context 104,857 vs forced num_ctx 4,096.
534        assert_eq!(
535            initial_send_budget(None, Some(104_857), Some(4_096)),
536            Some(3_276)
537        );
538        assert_eq!(
539            initial_send_budget(Some(104_857), Some(104_857), Some(4_096)),
540            Some(3_276)
541        );
542    }
543
544    /// Zero/tiny `num_ctx` must never become a compress-to-zero budget — the
545    /// zero-is-disabled contract (F3) holds at the source.
546    #[test]
547    fn zero_or_tiny_num_ctx_is_no_budget_at_all() {
548        assert_eq!(num_ctx_input_ceiling(None), None);
549        assert_eq!(num_ctx_input_ceiling(Some(0)), None);
550        assert_eq!(num_ctx_input_ceiling(Some(1)), None, "80% rounds to zero");
551        assert_eq!(initial_send_budget(None, None, Some(0)), None);
552        // A zero ceiling must not shadow a real cached budget either.
553        assert_eq!(initial_send_budget(Some(2_000), None, Some(0)), Some(2_000));
554    }
555}
556
557/// Everything one agentic turn needs, resolved once by the caller (the TUI
558/// resolves config + capability cache + caveats per turn and threads them in
559/// here, so the loop itself never re-reads config from disk).
560pub struct ChatCtx<'a> {
561    pub url: &'a str,
562    pub model: &'a str,
563    /// Wire protocol of the active backend (Ollama vs OpenAI-compatible).
564    pub kind: crate::BackendKind,
565    /// Bearer token for authenticated OpenAI-compatible endpoints.
566    pub api_key: Option<&'a str>,
567    /// Full message list already assembled by `MemoryManager::build_messages`.
568    pub messages: &'a [crate::MemMessage],
569    pub task: &'a str,
570    pub workspace: &'a str,
571    pub color: bool,
572    /// Render assistant Markdown as ANSI in the live stream (Step 25.4, #568).
573    /// Resolved by the caller as `[tui].markdown` (∧ `/markdown` override) ∧
574    /// color. The loop only renders when this is true.
575    pub markdown: bool,
576    /// Offload oversized tool results to the session spill store (Step 26.3,
577    /// #584). The resolved `tool_offload` composable feature (Step 26.1); false
578    /// for headless/eval callers (bit-for-bit unchanged when off).
579    pub tool_offload: bool,
580    /// Session spill store for `tool_offload` (Step 26.3). `None` = no offload
581    /// (and `spill:` re-reads resolve to a labelled absence). Shared `&dyn`
582    /// (interior mutability) so it serves both the write path and `memory_fetch`.
583    pub spill_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
584    /// Session compaction store (#661 group B): the compressor stores each
585    /// evicted (redacted) middle span here and names a `compaction:<id>` handle
586    /// in the marker, so the model can losslessly recover a detail the summary
587    /// dropped. SEPARATE store from `spill_store` (own id space). `None` =
588    /// lossy-only compaction (headless / progressive disclosure off).
589    pub compaction_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
590    /// Inject the `<state>` scratchpad block + advertise the state tools (Step
591    /// 26.4, #583). The resolved `scratchpad` feature; false for headless/eval.
592    pub scratchpad: bool,
593    /// Session scratchpad store (Step 26.4). `None` = state tools not advertised
594    /// and no `<state>` injected. Shared `&dyn` (interior mutability).
595    pub scratchpad_store: Option<&'a dyn crate::agentic::scratchpad::ScratchpadStore>,
596    /// Semantic searcher for the `code_search` tool (Step 26.5.5). `None` = the
597    /// tool is not advertised (semantic off / no index). Bundles embedder+index.
598    pub code_search: Option<crate::agentic::semantic::CodeSearch<'a>>,
599    /// Experiential store for the record/recall tools (Step 26.6a). `None` = the
600    /// tools are not advertised (experiential off). Shared `&dyn` (interior mut).
601    pub experience_store: Option<&'a dyn crate::agentic::experiential::ExperienceStore>,
602    /// Plan ledger for the update_plan tool (Step 26.6b). `None` = the tool is
603    /// not advertised (scheduled off). Shared `&dyn` (interior mut).
604    pub step_ledger: Option<&'a dyn crate::agentic::scheduled::StepLedger>,
605    pub caveats: &'a crate::caveats::Caveats,
606    /// Maximum tool-call rounds before forcing a final tools-disabled
607    /// completion (from `[tui].max_tool_rounds`, default 40).
608    pub max_tool_rounds: usize,
609    /// Additional progress-aware rounds available after `max_tool_rounds` when
610    /// the active workflow still has incomplete work and the recent rounds show
611    /// repair evidence or concrete progress. `0` makes the normal cap hard.
612    pub workflow_grace_rounds: usize,
613    /// Max lines of tool output shown inline (from `[tui].tool_output_lines`,
614    /// default 20). Resolved once per turn and threaded to `execute_tool` so
615    /// the tool loop never re-reads config from disk.
616    pub tool_output_lines: usize,
617    /// Enable per-round diagnostic output. Set via `NEWT_DEBUG=1` or the
618    /// `[tui] debug = true` config key.
619    pub debug: bool,
620    /// Enable deeper backend compatibility traces. Set via `NEWT_TRACE=1` or
621    /// the `[tui] trace = true` config key.
622    pub trace: bool,
623    /// Ollama `options.num_ctx` — caps KV-cache allocation to prevent VRAM
624    /// exhaustion on large models. `None` → model default (often 131k).
625    /// Also feeds the pre-send budget as a hard input ceiling for this turn's
626    /// requests (issue #282): Ollama silently evaluates only the window's
627    /// tail, so anything newt sends must already fit inside the `num_ctx` it
628    /// sends it with. Ignored on the OpenAI path (no such request field).
629    pub num_ctx: Option<u32>,
630    /// TCP connect timeout. Short (5 s default) so a down endpoint fails fast
631    /// rather than blocking the full `inference_timeout_secs`.
632    pub connect_timeout_secs: u64,
633    /// Total inference timeout. Must be long enough for the model to generate
634    /// a complete response (120 s default).
635    pub inference_timeout_secs: u64,
636    /// Message list size at which the agent trims the middle of the in-flight
637    /// conversation to prevent context overflow mid-turn.
638    pub mid_loop_trim_threshold: usize,
639    /// Estimated-token threshold that also triggers a mid-loop trim, regardless
640    /// of message count. Guards against a single huge tool result blowing past
641    /// the context window in one round (from `[tui].mid_loop_trim_tokens`).
642    /// `None` disables token-based trimming. See issue #223.
643    pub mid_loop_trim_tokens: Option<usize>,
644    /// Highest input-token count this model has accepted without a 400, from
645    /// `model-capabilities.json`. Used as the pre-send budget gate: requests
646    /// estimated to exceed it are trimmed *before* dispatch. `None` falls back
647    /// to `safe_context`. See issue #223.
648    pub max_ok_input: Option<u32>,
649    /// Shell command run after every successful file write to give the model
650    /// immediate ground-truth feedback (e.g. "cargo check -q --workspace").
651    /// `None` disables auto-checking. Set per-workspace in `.newt/config.toml`.
652    pub build_check_cmd: Option<String>,
653    /// Empirically derived safe context size for this model (input tokens).
654    /// Used to detect likely overflow when the model returns an empty response.
655    /// Sourced from `model-capabilities.json` via `ensure_context_window`.
656    /// `None` disables overflow detection.
657    pub safe_context: Option<u32>,
658    /// Hook invoked when a dispatch fails, to recover a hard context-window
659    /// 400: `(error, model, today) → new input-token cap`. The TUI wires its
660    /// `recover_context_window_400` (which parses the endpoint's real limit
661    /// and persists it to `model-capabilities.json` — that cache stays
662    /// TUI-side with the probe module). `None` disables recovery: the error
663    /// propagates exactly as it did when no limit could be parsed. See #223.
664    pub recover_cw_400: Option<RecoverCw400>,
665    /// Model-writable note store behind the `save_note` tool (Step 19.3,
666    /// #248). `None` ⇒ the tool is not advertised and the loop never writes
667    /// memory (eval / headless callers unaffected). The TUI passes a sink
668    /// over its session `MemoryManager`, so `save_note` and `/remember`
669    /// share one store, one security scan, one char budget.
670    pub note_sink: Option<&'a mut dyn NoteSink>,
671    /// Turn-counted memory-nudge state ([`NoteNudge`]), owned by the caller
672    /// across user turns and lent to the loop per call. Consulted only when
673    /// `note_sink` is present; `None` disables the nudge.
674    pub note_nudge: Option<&'a mut NoteNudge>,
675    /// Read-only search over PAST conversations behind the `recall` tool
676    /// (Step 17.5, #246). `None` ⇒ the tool is not advertised and the loop
677    /// never searches (eval / headless callers unaffected). The TUI passes
678    /// a [`StoreRecallSource`] over its session `ConversationStore` —
679    /// workspace-fenced, current conversation excluded.
680    pub recall_source: Option<&'a dyn RecallSource>,
681    /// Read-only pull of an ADDRESSED memory item behind the `memory_fetch`
682    /// tool (progressive-disclosure memory, Workstream A MVP, #319). `None` ⇒
683    /// the tool is not advertised and the loop never fetches — eval / headless
684    /// / ACP callers (which pass `memory_source: None`) are unaffected,
685    /// bit-for-bit. The TUI passes a [`StoreMemorySource`] over its session
686    /// `NoteStore` + `ConversationStore` (workspace-fenced), so `note:` and
687    /// `turn:` addresses resolve against the same surfaces `/remember` and
688    /// `recall` use. Gated exactly like `recall_source`.
689    pub memory_source: Option<&'a dyn MemorySource>,
690    /// Compression summarizer (Step 18.4, #247): given the redacted summary
691    /// request, returns the summary text — typically one tools-disabled
692    /// completion against the same backend (the TUI wires this, mirroring
693    /// the `Summarizing` provider's `with_summarizer` injection). `None`
694    /// (eval / headless) ⇒ the compression pipeline degrades to the static
695    /// "Summary generation was unavailable" marker instead of an LLM summary.
696    pub summarizer: Option<&'a SummarizeFn>,
697    /// Session-scoped compression anti-thrash state ([`CompressState`]),
698    /// owned by the caller across user turns and lent per call (the
699    /// `note_nudge` pattern). `None` ⇒ a fresh per-turn state.
700    pub compress_state: Option<&'a mut CompressState>,
701    /// Per-turn tool-event recorder (Step 17.6, #246): when present, the
702    /// loop pushes one [`crate::ToolEvent`] per executed tool call — name,
703    /// privacy-preserving args digest (never raw args), outcome, duration
704    /// claim — at the same site that renders tool activity live. The TUI
705    /// lends a fresh `Vec` per turn (the `note_nudge` pattern) and persists
706    /// it into the turn's `events` column. `None` (eval / headless) ⇒
707    /// nothing is recorded.
708    pub tool_events: Option<&'a mut Vec<crate::ToolEvent>>,
709    /// Per-turn phantom-reach recorder (#717): when present, the loop pushes one
710    /// [`crate::PhantomReach`] for each phantom tool/capability reach (alias
711    /// resolve, hallucination, or a real-tool empty-by-design miss). Lent fresh
712    /// per turn like `tool_events`; persisted into the turn's `phantom_reaches`.
713    /// `None` (eval / headless) ⇒ nothing recorded.
714    pub phantom_reaches: Option<&'a mut Vec<crate::PhantomReach>>,
715    /// Prompted ocap grants (issue #263): when present, a capability denial
716    /// inside `execute_tool` consults the human — allow once / session allow
717    /// / deny — instead of failing outright; the loop blocks like a long
718    /// tool call while the prompt is pending. `None` (the default — every
719    /// headless caller: ACP worker, `newt-eval`) keeps each denial exactly
720    /// as before, so nothing non-interactive can ever hang on a prompt.
721    pub permission_gate: Option<&'a mut dyn PermissionGate>,
722    /// Per-round capability observation hook (Phase 20,
723    /// `docs/design/model-self-tuning.md` §2.2): the loop reports each
724    /// round's evidence — accepted prompt sizes (with the matching chars/4
725    /// estimate for calibration), persistent-empty suspected overflows, the
726    /// thinking-only response quirk — at the moment of observation, so the
727    /// caller can persist it even when the turn later bails or errors (the
728    /// motivating failure discarded an accepted 8,734-token prompt because
729    /// the only write-back lived in the TUI's `Ok`-arm epilogue). `None`
730    /// (ACP worker, eval, cowork driver) preserves today's behavior exactly
731    /// (spec §5).
732    pub on_round_usage: Option<&'a mut dyn FnMut(RoundObservation)>,
733    /// Learned observed/estimated prompt-token ratio for this model
734    /// (Phase 20 §2.3), applied wherever chars/4 estimates meet
735    /// backend-reported token budgets — compression triggers, targets, and
736    /// the tool-schema overhead. `None` or out-of-clamp values degrade to
737    /// 1.0 (no calibration; pre-Phase-20 behavior).
738    pub estimate_ratio: Option<f32>,
739    /// `[context.estimation]` token-estimation heuristic (chars-per-token),
740    /// extracted from config so the loop never re-reads it — drives every
741    /// chars→token estimate and the budget→chars summary-cap conversion.
742    pub estimation: crate::tokens::TokenEstimation,
743    /// `[context] summary_input_cap_floor_chars` — floor for the summarizer
744    /// input cap so a tight budget never starves the summarizer of material.
745    pub summary_input_cap_floor_chars: usize,
746    /// #307 named-permission-preset exec FLOOR. When a `/mode` preset is active
747    /// its exec clamp is threaded here so the `--disable-ocap` / `--yolo`
748    /// bypass in `execute_tool` cannot raise exec authority above the preset:
749    /// an out-of-floor command falls through to the confined shell and is
750    /// denied. `None` (no active preset, and every headless caller) leaves the
751    /// bypass bit-for-bit. The floor is also already `meet`-ed into `caveats`,
752    /// so the confined-shell and gate paths enforce it too; this field is the
753    /// one extra place the otherwise caveats-blind bypass must consult.
754    pub exec_floor: Option<&'a crate::caveats::Scope<String>>,
755    /// `retry` technique (R2 action arm): a turn-scoped copy-on-first-write ledger
756    /// ([`crate::verify_gate::WriteLedger`]) the file-write tools record into before
757    /// each `write_file` / `edit_file`, so the caller can revert exactly the files
758    /// newt wrote this turn (and only those) after the gate runs. Shared via
759    /// [`RefCell`](std::cell::RefCell) so the loop records while the caller reads.
760    /// `None` (every headless caller, and any profile without `retry`) ⇒ nothing is
761    /// recorded and no file is ever reverted — bit-for-bit today's behavior.
762    pub write_ledger: Option<&'a std::cell::RefCell<crate::verify_gate::WriteLedger>>,
763    /// User-interrupt flag (Esc / Ctrl-C during a turn). When set mid-turn the
764    /// loop abandons at its next checkpoint — the round-loop top, and the two
765    /// model awaits (the non-streaming probe and the token stream) — and
766    /// returns early. `None` (every headless / eval caller) ⇒ no interrupt
767    /// path, bit-for-bit today's behavior. The caller owns the `AtomicBool`,
768    /// trips it from a keyboard watcher, and inspects it after the call to tell
769    /// an interrupted turn from a genuinely empty reply.
770    pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
771    /// The injected embedded-git capability (PR4, #461). `Some` ⇒ the `git`
772    /// tool is advertised and dispatches through it (`LocalGitTool` in
773    /// `newt-git`, injected by the binary). `None` (every headless / eval
774    /// caller, and any session not in a git repo) ⇒ the tool is never
775    /// advertised — bit-for-bit today's behavior. The trait-injection seam, not
776    /// a direct dep, because `newt-git` depends on `newt-core` (circular).
777    pub git_tool: Option<&'a dyn GitTool>,
778    /// The injected crew/team orchestration capability (#479). `Some` ⇒ the
779    /// `compose_roster` + `crew` tools are advertised and dispatch through it
780    /// (`LocalCrewRunner` in `newt-cli`, injected by the `/team` toggle). `None`
781    /// ⇒ never advertised. Trait-injection seam like `git_tool` (newt-scheduler
782    /// depends on newt-core, so the dep can't be direct).
783    pub crew_runner: Option<&'a dyn CrewRunner>,
784}
785
786/// retry technique (R2 action arm): before a `write_file`/`edit_file` is dispatched,
787/// capture the target's pre-write bytes into the turn ledger. Recorded at the loop's
788/// dispatch site (not inside `execute_tool`) so the seam stays narrow and only the
789/// two file-writing tools are ever tracked. [`WriteLedger::note_before_write`] is
790/// idempotent on the turn's first write of a path, so the *pre-turn* state is what
791/// is preserved. A no-op when no ledger is lent — every headless caller and any
792/// profile without `retry` — so behavior is bit-for-bit unchanged there.
793fn ledger_note_write(
794    write_ledger: Option<&std::cell::RefCell<crate::verify_gate::WriteLedger>>,
795    name: &str,
796    args: &serde_json::Value,
797    workspace: &str,
798) {
799    let Some(led) = write_ledger else {
800        return;
801    };
802    if name != "write_file" && name != "edit_file" {
803        return;
804    }
805    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
806        // Key on the lexically-normalized path so a raw model path like
807        // `examples/../foo.py` matches the gate's filesystem-normalized `foo.py`
808        // and the revert lookup actually hits — otherwise a non-normalized path
809        // would silently evade revert (the fabrication persists, the gate is gamed).
810        let abs = lexical_normalize(&std::path::Path::new(workspace).join(p));
811        led.borrow_mut().note_before_write(abs);
812    }
813}
814
815/// Lexically normalize a path — collapse `.`, resolve `..`, drop empty components —
816/// **without** touching the filesystem, so a ledger key built from a raw
817/// model-supplied path matches the gate's `read_dir`-normalized path. Purely
818/// lexical: it deliberately does not resolve symlinks (the gate never follows them
819/// and revert is workspace-boundary-guarded), so it cannot itself escape the tree.
820fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
821    use std::path::Component;
822    let mut out = std::path::PathBuf::new();
823    for comp in path.components() {
824        match comp {
825            Component::ParentDir => {
826                // Only pop a real path segment; never climb above a root/prefix.
827                if matches!(out.components().next_back(), Some(Component::Normal(_))) {
828                    out.pop();
829                } else {
830                    out.push("..");
831                }
832            }
833            Component::CurDir => {}
834            other => out.push(other.as_os_str()),
835        }
836    }
837    out
838}
839
840#[cfg(test)]
841mod retry_ledger_tests {
842    use super::*;
843
844    #[test]
845    fn lexical_normalize_collapses_dot_and_parent() {
846        assert_eq!(
847            lexical_normalize(std::path::Path::new("/ws/examples/../foo.py")),
848            std::path::PathBuf::from("/ws/foo.py")
849        );
850        assert_eq!(
851            lexical_normalize(std::path::Path::new("/ws/./a//b/foo.py")),
852            std::path::PathBuf::from("/ws/a/b/foo.py")
853        );
854        // A leading `..` with no segment to pop is preserved, not climbed past root.
855        assert_eq!(
856            lexical_normalize(std::path::Path::new("../x.py")),
857            std::path::PathBuf::from("../x.py")
858        );
859    }
860
861    #[test]
862    fn ledger_note_write_keys_on_the_normalized_path() {
863        let tmp = tempfile::tempdir().unwrap();
864        std::fs::write(tmp.path().join("foo.py"), "real\n").unwrap();
865        let led = std::cell::RefCell::new(crate::verify_gate::WriteLedger::new());
866        // a raw, non-normalized model path
867        let args = serde_json::json!({ "path": "examples/../foo.py" });
868        ledger_note_write(
869            Some(&led),
870            "write_file",
871            &args,
872            tmp.path().to_str().unwrap(),
873        );
874        // the key normalized to <ws>/foo.py — the same path the gate would produce —
875        // so revert finds and restores it (returns true).
876        assert!(
877            led.borrow().revert(&tmp.path().join("foo.py")).unwrap(),
878            "the normalized key matches the gate's path"
879        );
880        // a read-only tool is never recorded
881        ledger_note_write(
882            Some(&led),
883            "read_file",
884            &serde_json::json!({ "path": "foo.py" }),
885            tmp.path().to_str().unwrap(),
886        );
887        assert_eq!(led.borrow().len(), 1, "only write tools are tracked");
888    }
889}
890
891/// True when a backend 400 says the model can't accept a `tools` field.
892/// Ollama phrases it `"<model> does not support tools"`; OpenAI-compatible
893/// servers vary, so we also accept the looser `"not support tools"`. Used to
894/// drop tools and retry once, then keep them off for the turn (deepseek-r1).
895fn is_tools_unsupported_error(e: &anyhow::Error) -> bool {
896    let s = e.to_string().to_lowercase();
897    s.contains("does not support tools") || s.contains("not support tools")
898}
899
900/// True when Ollama accepted the `tools` field but its internal XML tool-call
901/// parser rejected the model's generated `<function>/<parameter>` markup before
902/// returning an assistant message. This is a malformed generation, not proof
903/// that the model lacks tool support, so retry with tools still advertised and
904/// a corrective nudge instead of disabling tools for the whole turn.
905fn is_ollama_tool_xml_error(e: &anyhow::Error) -> bool {
906    let s = e.to_string().to_lowercase();
907    s.contains("ollama") && s.contains("xml syntax error")
908}
909
910fn ollama_tool_xml_retry_nudge() -> &'static str {
911    "The previous assistant turn failed inside Ollama's XML tool-call parser. \
912     Keep using tools, but emit exactly one valid native tool call with well-formed \
913     arguments now. Do not answer in prose or wrap the call in explanatory XML."
914}
915
916/// Main agentic loop: call model → execute tool calls → feed results back → repeat.
917/// Returns `(reply_text, was_streamed, token_usage, hallucination_count)`.
918/// When `was_streamed` is true the text was already printed token-by-token.
919///
920/// Token-usage semantics (Step 18.1): `input_tokens` is the **largest single
921/// prompt** the backend evaluated across the turn's rounds — the truthful
922/// "how full did the context get" figure that feeds the capability ratchet —
923/// NOT the per-round sum, which double-counts history (every round's prompt
924/// re-includes all prior rounds; the B3 baseline measured 5.4× inflation).
925/// `output_tokens` is the sum across rounds (each completion is new).
926/// `true` once the caller's interrupt flag is set (Esc / Ctrl-C). Cheap relaxed
927/// load — the flag is a one-way latch, so no ordering guarantees are needed.
928fn is_cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) -> bool {
929    cancel.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
930}
931
932/// Resolve as soon as the interrupt flag is set, polling at ~50 ms — fine for a
933/// human keypress and cheap enough to lose the race to any real I/O future.
934/// Never resolves when `cancel` is `None`, so a `select!` against it collapses
935/// to just the other arm (no behavior change for headless callers).
936async fn cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) {
937    match cancel {
938        None => std::future::pending().await,
939        Some(flag) => {
940            // Poll briskly so an interrupt is felt promptly (well under the
941            // ~100 ms human threshold) — the cost is negligible next to a model
942            // round-trip.
943            while !flag.load(std::sync::atomic::Ordering::Relaxed) {
944                tokio::time::sleep(std::time::Duration::from_millis(15)).await;
945            }
946        }
947    }
948}
949
950/// Race a model future against the interrupt flag: `Some(v)` if it finished,
951/// `None` if the user interrupted first (the future is dropped, cancelling any
952/// in-flight request).
953async fn cancellable<F: std::future::Future>(
954    cancel: Option<&std::sync::atomic::AtomicBool>,
955    fut: F,
956) -> Option<F::Output> {
957    tokio::select! {
958        biased;
959        _ = cancelled(cancel) => None,
960        v = fut => Some(v),
961    }
962}
963
964/// Drive `fut` while animating the thinking line in place (~8 fps), so a slow
965/// non-streaming probe still feels alive — the braille/hourglass spinner
966/// otherwise only advances when reasoning tokens arrive. Clears the line when
967/// `fut` resolves so the following output starts clean. A no-op when `animate`
968/// is false (piped / `thinking = "off"` / no TTY) — bit-for-bit today's path.
969async fn with_thinking_spinner<F: std::future::Future>(
970    animate: bool,
971    label: &str,
972    fut: F,
973) -> F::Output {
974    if !animate {
975        return fut.await;
976    }
977    tokio::pin!(fut);
978    let start = std::time::Instant::now();
979    let mut frame = 0usize;
980    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(120));
981    loop {
982        tokio::select! {
983            biased;
984            out = &mut fut => {
985                let _ = write!(io::stdout(), "\r\x1b[K");
986                let _ = io::stdout().flush();
987                return out;
988            }
989            _ = ticker.tick() => {
990                let line = format_spinner(frame, start.elapsed().as_secs_f32(), label, 0);
991                let mut out = io::stdout();
992                let _ = execute!(
993                    out,
994                    Print("\r\x1b[K"),
995                    SetForegroundColor(CtColor::DarkGrey),
996                    Print(&line),
997                    ResetColor,
998                );
999                let _ = out.flush();
1000                frame = frame.wrapping_add(1);
1001            }
1002        }
1003    }
1004}
1005
1006pub async fn chat_complete(
1007    ctx: ChatCtx<'_>,
1008    mcp: &mut dyn McpTools,
1009) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
1010    // OpenAI-compatible endpoints speak a different wire format (request,
1011    // tool_calls, and usage shapes all differ), so they get their own loop.
1012    if ctx.kind == crate::BackendKind::Openai {
1013        // A backend with `api = "responses"` speaks the newer Responses API
1014        // (gpt-5-codex et al., served only there); the default stays on
1015        // /v1/chat/completions.
1016        if responses_api_selected() {
1017            return openai_responses_complete(ctx, mcp).await;
1018        }
1019        return openai_chat_complete(ctx, mcp).await;
1020    }
1021    // Step 25.4 (#568): capture the markdown decision before `ctx` is consumed
1022    // by the destructure (the destructures ignore it via `markdown: _`).
1023    let markdown = ctx.markdown;
1024    let ChatCtx {
1025        url,
1026        model,
1027        kind: _,
1028        api_key: _,
1029        messages: mem_messages,
1030        task,
1031        workspace,
1032        color,
1033        markdown: _,
1034        tool_offload,
1035        spill_store,
1036        compaction_store,
1037        scratchpad,
1038        scratchpad_store,
1039        code_search,
1040        experience_store,
1041        step_ledger,
1042        caveats,
1043        max_tool_rounds,
1044        workflow_grace_rounds,
1045        tool_output_lines,
1046        debug,
1047        trace,
1048        num_ctx,
1049        connect_timeout_secs,
1050        inference_timeout_secs,
1051        mid_loop_trim_threshold,
1052        mid_loop_trim_tokens,
1053        max_ok_input,
1054        build_check_cmd,
1055        safe_context,
1056        recover_cw_400,
1057        mut note_sink,
1058        mut note_nudge,
1059        recall_source,
1060        memory_source,
1061        summarizer,
1062        compress_state,
1063        mut tool_events,
1064        mut phantom_reaches,
1065        mut permission_gate,
1066        mut on_round_usage,
1067        estimate_ratio,
1068        estimation,
1069        summary_input_cap_floor_chars,
1070        exec_floor,
1071        write_ledger,
1072        cancel,
1073        git_tool,
1074        crew_runner,
1075    } = ctx;
1076    // Headless callers may pass no session state — compression still works,
1077    // with per-turn anti-thrash accounting.
1078    let mut local_compress_state = CompressState::new();
1079    let compress_state = match compress_state {
1080        Some(s) => s,
1081        None => &mut local_compress_state,
1082    };
1083    let client = reqwest::Client::builder()
1084        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1085        .timeout(std::time::Duration::from_secs(inference_timeout_secs))
1086        .build()?;
1087    // #643: the streaming re-issue (the final-text round consumed token-by-token
1088    // by `stream_response`) must NOT use a whole-request `.timeout()`. That bounds
1089    // connect + headers + the ENTIRE body, so a slow-but-progressing token stream
1090    // is aborted mid-flight the instant total time crosses the deadline, and the
1091    // retry envelope then restarts the full prefill — the DGX retry-storm wedge.
1092    // An IDLE `read_timeout` is the right bound: it caps the gap between chunks and
1093    // resets on every token, so a progressing stream runs as long as it keeps
1094    // producing while a genuinely stalled connection still bails after
1095    // `inference_timeout_secs` of silence. The one-shot `stream:false` probe below
1096    // keeps `client` (a whole-request bound is correct for a single-shot response).
1097    let stream_client = reqwest::Client::builder()
1098        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1099        .read_timeout(std::time::Duration::from_secs(inference_timeout_secs))
1100        .build()?;
1101    let chat_url = format!("{}/api/chat", url.trim_end_matches('/'));
1102    let retry = tui_retry_policy();
1103    // The save_note tool is advertised only when a sink exists (Step 19.3);
1104    // recall only when a source exists (Step 17.5); memory_fetch only when a
1105    // memory source exists (#319) — same presence gating.
1106    let advertise_save_note = note_sink.is_some();
1107    let advertise_recall = recall_source.is_some();
1108    let advertise_memory_fetch = memory_source.is_some();
1109    // Step 26.4 (#583): state tools only when the feature is on AND a store exists.
1110    let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
1111    // Step 26.5.5 (#582): the code_search tool when a searcher is present.
1112    let advertise_code_search = code_search.is_some();
1113    // Step 26.6a (#585): the experiential tools when a store is present.
1114    let advertise_experiential = experience_store.is_some();
1115    // Step 26.6b (#586): the scheduled plan tools when a ledger is present.
1116    let advertise_scheduled = step_ledger.is_some();
1117    let advertise_git = git_tool.is_some();
1118    let advertise_team = crew_runner.is_some();
1119
1120    // Convert MemMessage list to Ollama JSON format.
1121    // The memory manager already included the current task as the last user message.
1122    let mut messages: Vec<serde_json::Value> = mem_messages
1123        .iter()
1124        .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
1125        .collect();
1126
1127    // In-band memory nudge (Step 19.3): after `[memory] note_nudge_interval`
1128    // user turns with zero organic save_note use, append a one-line reminder
1129    // to this turn's user message. Only when a sink exists — without one the
1130    // save_note tool isn't even advertised.
1131    if note_sink.is_some() {
1132        if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
1133            append_nudge_line(&mut messages, &line);
1134        }
1135    }
1136
1137    let mut accumulated_usage: Option<crate::TokenUsage> = None;
1138    let mut hallucination_count: u32 = 0;
1139    // Step 27.3/#771: guard against exact-repeat tool loops this run.
1140    let mut repeat_calls = RepeatCallGuard::default();
1141    let mut overflow_retries: u32 = 0;
1142    let mut suspicious_empty_retries: u32 = 0;
1143    // Hard context-window 400s recovered (parse limit → trim → retry). See #223.
1144    let mut cw_retries: u32 = 0;
1145    // Some models reject ANY request carrying a `tools` field (e.g.
1146    // deepseek-r1). Once one 400s with "does not support tools", drop tools for
1147    // the rest of the session so even a bare "hello" works; notice it once.
1148    let mut tools_supported = true;
1149    let mut tools_unsupported_notified = false;
1150    // Pre-send token budget gate: trim before dispatch when the current context
1151    // size exceeds the model's empirically-confirmed max input (or the safe
1152    // context) — capped by the `num_ctx` every request this turn will carry
1153    // (#282: on a fresh capability cache the cached numbers are unset or huge,
1154    // so without the ceiling the first turn dispatched 10× over the real
1155    // window with zero events — B6). Mutable because a recovered 400 tightens
1156    // it mid-turn. See #223.
1157    let num_ctx_ceiling = num_ctx_input_ceiling(num_ctx);
1158    let mut send_budget: Option<usize> = initial_send_budget(max_ok_input, safe_context, num_ctx);
1159    // Step 20.3: is the send budget backed by an authoritative ceiling, or
1160    // does it rest on the proven-good high-water mark (`max_ok_input`) alone?
1161    // `safe_context` (a believed/declared window) and the per-request
1162    // `num_ctx` ceiling are authoritative; a cw-400 recovery flips this true
1163    // mid-turn. Cloud endpoints with no `/api/show` seed neither, so their
1164    // guard is non-authoritative and fails open instead of refusing.
1165    let mut send_budget_authoritative = safe_context.is_some() || num_ctx_ceiling.is_some();
1166    // Tool schemas ride along in every request body; count them once (18.1).
1167    // Stable for the whole turn: the builtin + MCP tool set doesn't change
1168    // mid-turn, so hoisting out of the round loop is safe.
1169    let tools = merged_tool_definitions(
1170        mcp,
1171        advertise_save_note,
1172        advertise_recall,
1173        advertise_memory_fetch,
1174        advertise_git,
1175        advertise_team,
1176        advertise_scratchpad,
1177        advertise_code_search,
1178        advertise_experiential,
1179        advertise_scheduled,
1180    );
1181    let tool_tokens = estimate_value_tokens(&tools, estimation);
1182    // Phase 20 §2.3: one sanitized calibration ratio per turn. The
1183    // tool-schema overhead converts to real-token space once — the schema
1184    // set is stable for the whole turn, and the send budget it is subtracted
1185    // from is real-token currency.
1186    let cal = sanitize_estimate_ratio(estimate_ratio);
1187    let tool_tokens_real = calibrate_up(tool_tokens, cal);
1188    // Animate the in-place "thinking…/compressing…" status line only on a TTY
1189    // with streaming enabled (never in a pipe / `newt worker`). Constant for the
1190    // turn — both the compression and probe waits reuse it.
1191    let animate = color && thinking_stream_enabled();
1192    // Truthful context-size tracker: anchors on the backend's last-reported
1193    // prompt token count, chars/4 + schema estimate as fallback (Step 18.1).
1194    let mut prompt_tracker = PromptTracker::new();
1195    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1196    // Consecutive rounds where the model only called read-only tools (no writes).
1197    // When this hits READ_ONLY_NUDGE_AFTER, a brief injected message tells the
1198    // model to stop exploring and start writing.
1199    let mut read_only_rounds: usize = 0;
1200    // "Narrate-then-stop" rescue: a weak model often ANNOUNCES its next action
1201    // in prose ("Let me edit …") and emits no tool call. The loop would treat
1202    // that zero-tool round as a final answer and end the turn, forcing a human
1203    // "continue". `narration_nudges` bounds the auto-continue that instead
1204    // nudges the model to actually call the tool (≤ NARRATION_NUDGE_CAP per
1205    // turn); the trigger is the configurable NudgeClassifier, so a genuine
1206    // conclusion (with or without prior tool calls this turn) is never nudged.
1207    let mut narration_nudges: usize = 0;
1208    // State-driven final-answer gate: a no-tool reply is suspicious when the
1209    // active plan still has open steps. Nudge once to update_plan / act / block.
1210    let mut pending_plan_nudges: usize = 0;
1211    // A stricter sibling: when a model concludes "the file changed under me"
1212    // without proving it, force one read-only ground-truth check round instead
1213    // of letting it hand the stale-context claim back to the human.
1214    let mut stale_file_nudges: usize = 0;
1215    let nudge_classifier = crate::NudgeClassifier::load_default();
1216    let workflow_steerer = crate::WorkflowSteerer::load_default();
1217    let mut workflow_runtime = WorkflowRuntimeState::default();
1218    // The matching workflow's round-cap grace horizon override, resolved once
1219    // from the turn's opening context (diagnostic workflows need more
1220    // read-only rounds between checkpoints than routine edits — see
1221    // `diagnose_failure.toml`'s `progress_horizon_rounds`).
1222    workflow_runtime.set_progress_horizon(
1223        workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
1224    );
1225    let mut ollama_xml_retry_nudges: usize = 0;
1226    // Phase 20 §2.2: the thinking-only quirk is reported at most once per
1227    // turn — re-detection adds no information and would thrash the cache.
1228    let mut thinking_only_reported = false;
1229    // #867 Part A: ledger of REAL workspace paths surfaced by tool results,
1230    // collected as the rounds happen so it survives the cap-exit trim.
1231    let mut observed_paths = claim_check::ObservedPaths::default();
1232    let observed_resolver = claim_check::workspace_resolver(workspace);
1233
1234    // Agentic loop — up to `max_tool_rounds` tool-call rounds, with an optional
1235    // evidence-backed grace window when the normal cap lands during active
1236    // workflow progress. The hard ceiling remains finite and configurable.
1237    let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
1238    let mut workflow_grace_active = false;
1239    let mut current_tool_round_limit = max_tool_rounds;
1240    'round_loop: for round in 0..hard_tool_rounds {
1241        if round >= current_tool_round_limit {
1242            if workflow_grace_active {
1243                break;
1244            }
1245            let Some(nudge) = workflow_runtime.cap_grace_nudge(
1246                step_ledger,
1247                max_tool_rounds,
1248                workflow_grace_rounds,
1249            ) else {
1250                break;
1251            };
1252            workflow_grace_active = true;
1253            current_tool_round_limit = hard_tool_rounds;
1254            if debug {
1255                print_debug(
1256                    "workflow progress at soft round cap — granting configured grace window",
1257                    color,
1258                );
1259            }
1260            messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1261        }
1262        // Interrupt checkpoint (Esc / Ctrl-C): bail before spending another
1263        // round on the model or a tool. The reply is empty — the caller sees
1264        // `cancel` set and treats the turn as abandoned regardless.
1265        if is_cancelled(cancel) {
1266            return Ok((String::new(), false, accumulated_usage, hallucination_count));
1267        }
1268        if round > 0 {
1269            // Brief separator between rounds so user can follow the flow.
1270            if color {
1271                execute!(
1272                    io::stdout(),
1273                    SetForegroundColor(CtColor::DarkGrey),
1274                    Print("…\n"),
1275                    ResetColor
1276                )
1277                .ok();
1278            }
1279        }
1280
1281        // Conditional plan re-seat (#630 b): re-show the ACTIVE step each round
1282        // so a weak model doesn't lose track of a multi-step plan as the round-0
1283        // <plan> snapshot goes stale (validated on dgx1: re-seat 12/12 vs baseline
1284        // 8/12 under drift). Compact + gated to multi-step in-progress plans.
1285        // Supersedes the env-gated NEWT_RESEAT_PLAN experiment that #629 carried
1286        // to main by accident.
1287        if round > 0 {
1288            if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
1289                messages.push(serde_json::json!({ "role": "user", "content": ptr }));
1290            }
1291            if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
1292                messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1293            }
1294        }
1295
1296        // Read-only round nudge: if the model has spent several consecutive
1297        // rounds only reading (list_dir / read_file / web_fetch / search /
1298        // use_skill) without writing anything, inject a brief reminder to
1299        // stop exploring and call edit_file or write_file.  This breaks the
1300        // "endless exploration → empty response" failure mode seen with some
1301        // local models (e.g. nemotron3:33b).
1302        const READ_ONLY_NUDGE_AFTER: usize = 3;
1303        if read_only_rounds >= READ_ONLY_NUDGE_AFTER {
1304            let remaining = current_tool_round_limit.saturating_sub(round + 1);
1305            // Sustained read-only exploration on a task that classifies as a
1306            // diagnose/fix workflow is exactly the shape `crew`/`team`
1307            // delegation exists for — offer it here (only when sub-agent
1308            // dispatch is actually available this session) instead of only
1309            // ever telling the model to act inline.
1310            let delegate_hint = workflow_steerer
1311                .delegate_hint(&workflow_classifier_text(&messages, ""), advertise_team);
1312            messages.push(serde_json::json!({
1313                "role": "user",
1314                "content": read_only_action_nudge(
1315                    read_only_rounds,
1316                    remaining,
1317                    step_ledger,
1318                    delegate_hint.as_deref(),
1319                )
1320            }));
1321            read_only_rounds = 0;
1322        }
1323
1324        // Context compression (Step 18.4, #247): one shared pipeline —
1325        // structural prune → boundary → redacted LLM summary → marker
1326        // assembly — serves both the mid-loop trigger (message count OR
1327        // current tokens: the VRAM guard and issue #223's token guard) and
1328        // the pre-send budget guard (`max_ok_input`/`safe_context`). The
1329        // current-token figure is prompt-tokens-preferred (Step 18.1). The
1330        // old amputation trim survives only as the pipeline's no-summarizer
1331        // static-marker path.
1332        {
1333            // Phase 20 §2.3: `current` is calibrated into real-token space —
1334            // the same currency as the (backend-derived) send budget and the
1335            // configured token threshold it is compared against.
1336            let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
1337            // The count-only budget is priced in message-token space — the
1338            // same chars/4 currency the pipeline compares its budget against
1339            // (F1); `current` (schema/template-inclusive) still drives the
1340            // token triggers.
1341            let message_tokens = estimate_tokens(&messages, estimation);
1342            if let Some(trigger) = compression_trigger(
1343                messages.len(),
1344                current,
1345                message_tokens,
1346                mid_loop_trim_threshold,
1347                mid_loop_trim_tokens,
1348                send_budget,
1349                tool_tokens_real,
1350            ) {
1351                // A hard trigger's budget is real-token currency; the
1352                // pipeline measures and reclaims in chars/4 — convert once
1353                // (Phase 20 §2.3). Count-only budgets are already priced in
1354                // message-token space (F1) and pass through unconverted.
1355                let pipeline_budget = if trigger.hard_budget {
1356                    calibrate_down(trigger.budget, cal)
1357                } else {
1358                    trigger.budget
1359                };
1360                // Step 20.3: does this budget rest on an authoritative ceiling
1361                // or the lone proven-good HWM? A fired token threshold is
1362                // user-authoritative; otherwise the guard fired, authoritative
1363                // only when the send budget is backed by a believed window.
1364                let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
1365                // Compression makes its own summarizer model call — animate the
1366                // line with a "compressing context…" stage so it doesn't sit
1367                // frozen, and race it against the interrupt flag so Esc bails
1368                // out of a slow summarize instead of waiting for it to finish.
1369                let outcome = match with_thinking_spinner(
1370                    animate,
1371                    "compressing context…",
1372                    cancellable(
1373                        cancel,
1374                        compress(
1375                            CompressRequest {
1376                                messages: &messages,
1377                                budget: pipeline_budget,
1378                                max_messages: trigger.max_messages,
1379                                task,
1380                                hard_budget: trigger.hard_budget,
1381                                authoritative: token_fired || send_budget_authoritative,
1382                                focus: None,
1383                                est: estimation,
1384                                summary_input_cap_floor_chars,
1385                                compaction_store,
1386                            },
1387                            summarizer,
1388                            compress_state,
1389                        ),
1390                    ),
1391                )
1392                .await
1393                {
1394                    Some(o) => o,
1395                    None => {
1396                        return Ok((String::new(), false, accumulated_usage, hallucination_count))
1397                    }
1398                };
1399                if let Some(notice) = outcome.notice {
1400                    print_harness_notice(&notice, color);
1401                }
1402                if outcome.action == CompressAction::Refused {
1403                    // Anti-thrash disabled compression and the context still
1404                    // exceeds the budget: refuse the send rather than let the
1405                    // backend silently truncate the task away (baseline B6).
1406                    // Phase 20: name the model and the reset escape hatch —
1407                    // a poisoned learned budget is a known cause of this bail.
1408                    anyhow::bail!(
1409                        "context (~{current} tokens) exceeds the model's input budget and \
1410                         auto-compression is disabled after repeated ineffective passes — \
1411                         start a new conversation or ask a more focused question, or run \
1412                         `newt tunings reset {model}` if this model's learned budget looks wrong"
1413                    );
1414                }
1415                if outcome.fired {
1416                    // N2: a hard-budget compression whose assembled result is
1417                    // still over budget says so — the dispatch proceeds (the
1418                    // cw-400/overflow recovery bounds it), but visibly. The
1419                    // comparison is in the SAME (chars/4) currency the
1420                    // pipeline measured `tokens_after` in (Phase 20 §2.3).
1421                    let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
1422                        ", still over budget"
1423                    } else {
1424                        ""
1425                    };
1426                    emit_compression_notice(
1427                        color,
1428                        outcome.tokens_before,
1429                        outcome.tokens_after,
1430                        outcome.action,
1431                        suffix,
1432                    );
1433                    if debug {
1434                        print_debug(
1435                            &format!(
1436                                "compression: {} → {} messages (budget ~{} tokens, \
1437                                 +~{tool_tokens} tool-schema tokens ride along)",
1438                                messages.len(),
1439                                outcome.messages.len(),
1440                                pipeline_budget,
1441                            ),
1442                            color,
1443                        );
1444                    }
1445                    messages = outcome.messages;
1446                    prompt_tracker.invalidate();
1447                }
1448            }
1449        }
1450
1451        // Phase 20 §2.2: chars/4 estimate of EXACTLY the request about to be
1452        // dispatched (the message list as sent, plus tool schemas) — paired
1453        // with the backend's reported prompt size in the `Accepted`
1454        // observation so the caller can learn the calibration ratio.
1455        let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
1456
1457        // Tool-call rounds: stream:false (fast, just JSON).
1458        // Final text round: stream:true so the user sees tokens arrive.
1459        // We don't know which round is last, so we probe with stream:false first
1460        // and switch to streaming only when the model returns no tool calls.
1461        let mut body_no_stream = if let Some(ctx_size) = num_ctx {
1462            serde_json::json!({
1463                "model": model,
1464                "messages": messages,
1465                "stream": false,
1466                "tools": tools.clone(),
1467                "options": { "num_ctx": ctx_size },
1468            })
1469        } else {
1470            serde_json::json!({
1471                "model": model,
1472                "messages": messages,
1473                "stream": false,
1474                "tools": tools.clone(),
1475            })
1476        };
1477        // Drop tools entirely for a model that rejects them (set below on a
1478        // "does not support tools" 400) — an empty array still trips strict
1479        // models, so remove the key.
1480        if !tools_supported {
1481            if let Some(o) = body_no_stream.as_object_mut() {
1482                o.remove("tools");
1483            }
1484        }
1485
1486        // Retry the send+status+parse as one unit — a connection drop at any
1487        // of these steps is transient and worth retrying with backoff. Raced
1488        // against the interrupt flag so Esc bails out of a slow / stuck probe
1489        // (the common "the model isn't answering" case) without waiting for it.
1490        // Wrapped in the thinking animation so this otherwise-silent wait shows
1491        // a live hourglass + clock instead of a frozen line.
1492        let dispatch = match with_thinking_spinner(
1493            animate,
1494            "thinking…",
1495            cancellable(
1496                cancel,
1497                with_backoff_notify(
1498                    &retry,
1499                    || async {
1500                        let resp = client
1501                            .post(&chat_url)
1502                            .json(&body_no_stream)
1503                            .send()
1504                            .await
1505                            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
1506                        if !resp.status().is_success() {
1507                            let status = resp.status();
1508                            let text = resp.text().await.unwrap_or_default();
1509                            anyhow::bail!("Ollama {status}: {text}");
1510                        }
1511                        resp.json::<serde_json::Value>()
1512                            .await
1513                            .map_err(anyhow::Error::from)
1514                    },
1515                    |attempt, delay| print_retry_indicator(attempt, delay, color),
1516                ),
1517            ),
1518        )
1519        .await
1520        {
1521            Some(d) => d,
1522            // Interrupted mid-probe: abandon the turn.
1523            None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
1524        };
1525        let json: serde_json::Value = match dispatch {
1526            Ok(j) => j,
1527            Err(e) => {
1528                // No-tools recovery: a model that rejects the `tools` field
1529                // (deepseek-r1) 400s even on "hello". Drop tools, notice once,
1530                // and re-dispatch the same turn — self-limiting because the
1531                // rebuilt body omits tools. A malformed XML tool-call error is
1532                // different: Ollama accepted tools but choked on the model's
1533                // generated markup, so keep tools available and retry with a
1534                // bounded corrective nudge.
1535                let tools_unsupported = is_tools_unsupported_error(&e);
1536                let malformed_xml_tool_call = is_ollama_tool_xml_error(&e);
1537                if tools_supported && tools_unsupported {
1538                    tools_supported = false;
1539                    if !tools_unsupported_notified {
1540                        tools_unsupported_notified = true;
1541                        let notice = format!(
1542                            "{model} does not support tools — tools disabled for this turn"
1543                        );
1544                        print_newt(&notice, color, false);
1545                    }
1546                    continue 'round_loop;
1547                }
1548                if tools_supported && malformed_xml_tool_call && ollama_xml_retry_nudges < 2 {
1549                    ollama_xml_retry_nudges += 1;
1550                    print_newt(
1551                        &format!(
1552                            "{model} produced malformed Ollama XML tool-call syntax — \
1553                             retrying with a stricter tool-call nudge"
1554                        ),
1555                        color,
1556                        false,
1557                    );
1558                    messages.push(serde_json::json!({
1559                        "role": "user",
1560                        "content": ollama_tool_xml_retry_nudge()
1561                    }));
1562                    continue 'round_loop;
1563                }
1564                // Graceful context-window 400 recovery: parse the model's real
1565                // limit, tighten the budget, compress, and retry once (#223;
1566                // compress-not-trim since Step 18.4).
1567                if cw_retries < 2 {
1568                    if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
1569                        emit_overflow_notice(
1570                            color,
1571                            accumulated_usage.as_ref(),
1572                            Some(new_cap),
1573                            model,
1574                            cw_retries + 1,
1575                        );
1576                        // A recovered cap can only tighten — the request still
1577                        // carries the same `num_ctx`, so its ceiling holds (#282).
1578                        let new_budget =
1579                            num_ctx_ceiling.map_or(new_cap as usize, |c| (new_cap as usize).min(c));
1580                        send_budget = Some(new_budget);
1581                        // The endpoint's parsed hard limit is authoritative —
1582                        // a refuse on it is correct from here on (Step 20.3).
1583                        send_budget_authoritative = true;
1584                        let outcome = compress(
1585                            CompressRequest {
1586                                // Real-token budget minus real-token schema
1587                                // overhead, converted into the pipeline's
1588                                // chars/4 currency (Phase 20 §2.3).
1589                                messages: &messages,
1590                                budget: calibrate_down(
1591                                    new_budget.saturating_sub(tool_tokens_real),
1592                                    cal,
1593                                ),
1594                                max_messages: None,
1595                                task,
1596                                hard_budget: true,
1597                                authoritative: true,
1598                                focus: None,
1599                                est: estimation,
1600                                summary_input_cap_floor_chars,
1601                                compaction_store,
1602                            },
1603                            summarizer,
1604                            compress_state,
1605                        )
1606                        .await;
1607                        if let Some(notice) = outcome.notice {
1608                            print_harness_notice(&notice, color);
1609                        }
1610                        if outcome.action == CompressAction::Refused {
1611                            // Refuse the resend; surface the endpoint's 400.
1612                            return Err(e);
1613                        }
1614                        if outcome.fired {
1615                            messages = outcome.messages;
1616                            prompt_tracker.invalidate();
1617                        }
1618                        cw_retries += 1;
1619                        continue 'round_loop;
1620                    }
1621                }
1622                return Err(e);
1623            }
1624        };
1625
1626        // Merge token usage from this non-streaming probe round (input = max
1627        // single prompt, output = sum — Step 18.1) and anchor the context-size
1628        // tracker on the backend-reported prompt size of this dispatch.
1629        let round_usage = ollama_usage(&json);
1630        if let Some(u) = round_usage {
1631            prompt_tracker.record(u.input_tokens, messages.len());
1632        }
1633        accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
1634
1635        // Phase 20 §2.2: a prompt within 5% of the request's `num_ctx` may
1636        // have been silently head-truncated by Ollama — such a round is
1637        // window evidence of NOTHING and must neither raise the budget nor
1638        // emit an `Accepted` observation.
1639        let truncation_suspect = round_usage
1640            .is_some_and(|u| num_ctx.is_some_and(|c| u.input_tokens >= c.saturating_mul(95) / 100));
1641        // Mid-turn budget raise on window evidence alone: the backend just
1642        // evaluated this many prompt tokens inside the `num_ctx` it was sent,
1643        // so one over-budget acceptance stops the compress-every-round thrash
1644        // within the same turn (Phase 20 §2.2). Never lowers; stays under the
1645        // per-request input ceiling.
1646        if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
1647            let raised = (u.input_tokens as usize).min(num_ctx_ceiling.unwrap_or(usize::MAX));
1648            if raised > budget {
1649                send_budget = Some(raised);
1650                if debug {
1651                    print_debug(
1652                        &format!(
1653                            "send budget raised to ~{raised} tokens (backend accepted \
1654                             {}-token prompt)",
1655                            u.input_tokens
1656                        ),
1657                        color,
1658                    );
1659                }
1660            }
1661        }
1662
1663        let message = &json["message"];
1664        // Capture the probe content now — it may be our only copy of the
1665        // model's reply if the subsequent streaming re-issue returns empty.
1666        let probe_content = message["content"].as_str().unwrap_or("").to_string();
1667
1668        let native_calls = message["tool_calls"].as_array();
1669        // Recover tool calls a weak model emitted in CONTENT instead of the
1670        // native `tool_calls` field — the #1 weak-model failure (see
1671        // `tool_recovery`). Only attempted when the native array is empty;
1672        // recovered calls are produced in native shape and flow unchanged into
1673        // the executor + `is_hallucination` + dup-guard + caveat path below.
1674        let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
1675            tool_recovery::recover_tool_calls(&probe_content)
1676        } else {
1677            tool_recovery::Recovery::default()
1678        };
1679        let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
1680            Some(t) if !t.is_empty() => Some(t),
1681            _ if !recovered.calls.is_empty() => Some(&recovered.calls),
1682            _ => None,
1683        };
1684        let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
1685        if debug && !recovered.calls.is_empty() {
1686            print_debug(
1687                &format!(
1688                    "recovered {} tool call(s) from content (non-native emission)",
1689                    recovered.calls.len()
1690                ),
1691                color,
1692            );
1693        }
1694
1695        if debug {
1696            let content_excerpt = if probe_content.is_empty() {
1697                "(empty)".to_string()
1698            } else {
1699                let chars: String = probe_content.chars().take(80).collect();
1700                if probe_content.len() > 80 {
1701                    format!("{chars}…")
1702                } else {
1703                    chars
1704                }
1705            };
1706            let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
1707            let usage_str = match round_usage {
1708                Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
1709                None => "no usage".into(),
1710            };
1711            print_debug(
1712                &format!(
1713                    "round {round} probe: tool_calls={tc_count} usage=[{usage_str}] content={content_excerpt:?}"
1714                ),
1715                color,
1716            );
1717        }
1718
1719        if !has_tools {
1720            // Format-hallucination tracker: the content looked like a tool-call
1721            // attempt but could not be recovered into one — count it so cap-exit
1722            // and metrics see a tooling failure, not a clean final answer.
1723            if recovered.tool_shaped {
1724                hallucination_count += 1;
1725                if debug {
1726                    print_debug(
1727                        "format-hallucination: tool call emitted as unrecoverable text",
1728                        color,
1729                    );
1730                }
1731            }
1732            // A final text candidate can come from either the streaming re-issue
1733            // or the non-streamed probe fallback. Run both through the same
1734            // no-tool final-answer gates so "Let me inspect..." does not force a
1735            // human "continue" just because the stream returned empty.
1736            macro_rules! maybe_nudge_no_tool_content {
1737                ($content:expr, $usage:expr) => {{
1738                    let content = $content;
1739                    if !content.is_empty() {
1740                        let nudge_classification = nudge_classifier.classify(content);
1741                        let workflow_classifier_text =
1742                            workflow_classifier_text(&messages, content);
1743                        let workflow_hint = nudge_classification
1744                            .is_plan_update()
1745                            .then(|| workflow_steerer.plan_update_hint(&workflow_classifier_text))
1746                            .flatten();
1747                        let classifier_plan_direction = nudge_classification
1748                            .is_plan_update()
1749                            .then(|| {
1750                                nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate)
1751                            })
1752                            .flatten();
1753                        let plan_nudge_hint =
1754                            combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
1755                        if round + 1 < current_tool_round_limit {
1756                            if let Some(nudge) = workflow_runtime.rediscovery_nudge(
1757                                Some(&nudge_classification),
1758                                content,
1759                                step_ledger,
1760                            ) {
1761                                if debug {
1762                                    print_debug(
1763                                        "workflow evidence rediscovery — nudging toward active repair",
1764                                        color,
1765                                    );
1766                                }
1767                                messages.push(serde_json::json!({
1768                                    "role": "assistant",
1769                                    "content": content
1770                                }));
1771                                messages.push(serde_json::json!({
1772                                    "role": "user",
1773                                    "content": nudge
1774                                }));
1775                                accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1776                                continue 'round_loop;
1777                            }
1778                        }
1779                        if pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
1780                            && round + 1 < current_tool_round_limit
1781                        {
1782                            if let Some(nudge) = pending_plan_completion_nudge(
1783                                step_ledger,
1784                                nudge_classification.is_plan_update(),
1785                                plan_nudge_hint.as_deref(),
1786                            ) {
1787                                if debug {
1788                                    print_debug(
1789                                        "active plan has unfinished steps — nudging before final answer",
1790                                        color,
1791                                    );
1792                                }
1793                                messages.push(serde_json::json!({
1794                                    "role": "assistant",
1795                                    "content": content
1796                                }));
1797                                messages.push(serde_json::json!({
1798                                    "role": "user",
1799                                    "content": nudge
1800                                }));
1801                                pending_plan_nudges += 1;
1802                                accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1803                                continue 'round_loop;
1804                            }
1805                        }
1806                        if stale_file_nudges < STALE_FILE_NUDGE_CAP
1807                            && round + 1 < current_tool_round_limit
1808                            && looks_like_unverified_stale_file_blocker(content)
1809                        {
1810                            if debug {
1811                                print_debug(
1812                                    "unverified stale-file blocker — nudging to check ground truth",
1813                                    color,
1814                                );
1815                            }
1816                            messages.push(serde_json::json!({
1817                                "role": "assistant",
1818                                "content": content
1819                            }));
1820                            messages.push(serde_json::json!({
1821                                "role": "user",
1822                                "content": stale_file_ground_truth_nudge(),
1823                            }));
1824                            stale_file_nudges += 1;
1825                            accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1826                            continue 'round_loop;
1827                        }
1828                        if narration_nudges < NARRATION_NUDGE_CAP
1829                            && round + 1 < current_tool_round_limit
1830                            && nudge_classification.is_pending_action()
1831                        {
1832                            if debug {
1833                                print_debug(
1834                                    "narrated intent with no tool call — nudging to act and continuing",
1835                                    color,
1836                                );
1837                            }
1838                            // Record the model's own narration, then the
1839                            // corrective, so the next round sees both (mirrors
1840                            // the has-tools assistant turn).
1841                            messages.push(serde_json::json!({
1842                                "role": "assistant",
1843                                "content": content
1844                            }));
1845                            let direction = nudge_classifier
1846                                .direction_for(nudge_classification.class)
1847                                .map(str::to_string)
1848                                .unwrap_or_else(narration_action_nudge);
1849                            messages.push(serde_json::json!({
1850                                "role": "user",
1851                                "content": direction,
1852                            }));
1853                            narration_nudges += 1;
1854                            accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1855                            continue 'round_loop;
1856                        }
1857                    }
1858                }};
1859            }
1860            // No tool calls — re-issue with stream:true so the user sees tokens.
1861            // `messages` already contains the task; just replay with streaming.
1862            //
1863            // IMPORTANT: the probe round already generated the model's answer in
1864            // `probe_content`. The streaming re-issue is a *second* inference call
1865            // from the same history; if it returns empty (non-determinism, context
1866            // pressure, or model quirk) we fall back to the probe content so the
1867            // user never sees a silent blank response.
1868            let mut body_stream = if let Some(ctx_size) = num_ctx {
1869                serde_json::json!({
1870                    "model": model,
1871                    "messages": &messages,
1872                    "stream": true,
1873                    "tools": tools.clone(),
1874                    "options": { "num_ctx": ctx_size },
1875                })
1876            } else {
1877                serde_json::json!({
1878                    "model": model,
1879                    "messages": &messages,
1880                    "stream": true,
1881                    "tools": tools.clone(),
1882                })
1883            };
1884            // A no-tools model (set on a prior "does not support tools" 400)
1885            // must not see the key on the streaming round either.
1886            if !tools_supported {
1887                if let Some(o) = body_stream.as_object_mut() {
1888                    o.remove("tools");
1889                }
1890            }
1891            // Retry the connection; if we connect successfully but the stream
1892            // drops mid-token, that's a separate (harder) failure mode. Raced
1893            // against the interrupt flag like the probe above.
1894            let sresp = match cancellable(
1895                cancel,
1896                with_backoff_notify(
1897                    &retry,
1898                    || async {
1899                        stream_client
1900                            .post(&chat_url)
1901                            .json(&body_stream)
1902                            .send()
1903                            .await
1904                            .map_err(|e| anyhow::anyhow!("stream request failed: {e}"))
1905                    },
1906                    |attempt, delay| print_retry_indicator(attempt, delay, color),
1907                ),
1908            )
1909            .await
1910            {
1911                Some(r) => r?,
1912                None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
1913            };
1914
1915            if !sresp.status().is_success() {
1916                if debug {
1917                    print_debug("stream request non-2xx — using probe content", color);
1918                }
1919                maybe_nudge_no_tool_content!(probe_content.as_str(), None);
1920                // Phase 20 §2.2: the probe round produced usable content —
1921                // quality gate met, report it before returning.
1922                if !probe_content.is_empty() {
1923                    emit_accepted(
1924                        &mut on_round_usage,
1925                        round_usage,
1926                        truncation_suspect,
1927                        round_est_raw,
1928                    );
1929                }
1930                return Ok((probe_content, false, accumulated_usage, hallucination_count));
1931            }
1932            // Cargo-style reasoning spinner: TTY-gated (`color`) and opt-out via
1933            // `[tui] thinking = "off"`. Never in a pipe / `newt worker`.
1934            let show_thinking = color && thinking_stream_enabled();
1935            // #528: models that stream a lone-leading `</think>` (Nemotron et al.)
1936            // need the filter to start inside the reasoning block so the closer
1937            // and the reasoning it follows don't leak into the reply.
1938            let leading_reasoning = crate::reasoning::emits_leading_reasoning(model);
1939            // Step 25.4 (#568): `markdown` is now resolved by the caller
1940            // (`[tui].markdown` ∧ `/markdown` override ∧ color) and read off the
1941            // ctx above — no longer hardcoded to `color`.
1942            let (streamed, stream_usage) = match stream_response(
1943                sresp,
1944                color,
1945                show_thinking,
1946                leading_reasoning,
1947                cancel,
1948                markdown,
1949            )
1950            .await
1951            {
1952                Ok(v) => v,
1953                Err(e) => {
1954                    // #640: the stream connected (2xx) but the BODY broke
1955                    // mid-response — the backend dropped/truncated the stream, or
1956                    // an idle gap exceeded the read timeout. `stream_response`'s
1957                    // only fallible step is the `resp.chunk()` body read, so any
1958                    // error here IS a mid-stream break; left to `?` it surfaces as
1959                    // an opaque "error decoding response body" and ends the whole
1960                    // turn. It is recoverable, not fatal: the `stream:false` probe
1961                    // above already produced the full answer in `probe_content`.
1962                    // Warn and fall back to it — the SAME recovery as the non-2xx
1963                    // path above. (Tiers 2+ of the ladder — retry / shrink /
1964                    // fallback-model / prompt-and-save-preference — are #640.)
1965                    print_harness_notice(
1966                        &format!(
1967                            "stream broke mid-response ({e}) — recovered the answer \
1968                             from the non-streamed probe"
1969                        ),
1970                        color,
1971                    );
1972                    if !probe_content.is_empty() {
1973                        maybe_nudge_no_tool_content!(probe_content.as_str(), None);
1974                        emit_accepted(
1975                            &mut on_round_usage,
1976                            round_usage,
1977                            truncation_suspect,
1978                            round_est_raw,
1979                        );
1980                    }
1981                    return Ok((probe_content, false, accumulated_usage, hallucination_count));
1982                }
1983            };
1984
1985            if streamed.is_empty() {
1986                // The streaming re-issue produced no tokens. Fall back to the
1987                // probe content rather than returning silence.
1988                if debug {
1989                    print_debug(
1990                        &format!(
1991                            "stream returned empty — falling back to probe content ({} chars)",
1992                            probe_content.len()
1993                        ),
1994                        color,
1995                    );
1996                }
1997                if probe_content.is_empty() {
1998                    let merged = merge_round_usage(accumulated_usage, stream_usage);
1999                    let empty_round_usage = merge_round_usage(round_usage, stream_usage);
2000                    let generated_unusable_output = empty_round_usage
2001                        .as_ref()
2002                        .map(|u| u.output_tokens > 0)
2003                        .unwrap_or(false);
2004
2005                    if generated_unusable_output
2006                        && suspicious_empty_retries < SUSPICIOUS_EMPTY_RETRY_CAP
2007                    {
2008                        if trace {
2009                            print_trace(&ollama_response_shape(&json), color);
2010                        }
2011                        if debug {
2012                            let fields = ollama_non_content_fields(&json);
2013                            let field_note = if fields.is_empty() {
2014                                "no known non-content fields".to_string()
2015                            } else {
2016                                format!("non-content fields: {}", fields.join(", "))
2017                            };
2018                            print_debug(
2019                                &format!(
2020                                    "empty assistant content with generated tokens — retrying ({}/{SUSPICIOUS_EMPTY_RETRY_CAP}; {field_note})",
2021                                    suspicious_empty_retries + 1
2022                                ),
2023                                color,
2024                            );
2025                        }
2026                        // Phase 20 §2.2: empty content carrying non-content
2027                        // fields is the thinking-only quirk — report it at
2028                        // detection (at most once per turn) so the prompt-
2029                        // inflating corrective retry isn't re-learned from
2030                        // scratch every session.
2031                        if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2032                            thinking_only_reported = true;
2033                            if let Some(hook) = on_round_usage.as_deref_mut() {
2034                                hook(RoundObservation::ThinkingOnly);
2035                            }
2036                        }
2037                        messages.push(serde_json::json!({
2038                            "role": "user",
2039                            "content": suspicious_empty_retry_nudge(suspicious_empty_retries, &json)
2040                        }));
2041                        accumulated_usage = merged;
2042                        suspicious_empty_retries += 1;
2043                        continue 'round_loop;
2044                    }
2045
2046                    // Both probe and stream are empty — likely context overflow.
2047                    // `input_tokens` is the largest single prompt evaluated this
2048                    // turn (Step 18.1), so the 85%-of-safe-context check now
2049                    // compares one real prompt against the window instead of a
2050                    // multi-round sum that inflated past it after ~2 rounds.
2051                    let overflow_likely = merged
2052                        .as_ref()
2053                        .zip(safe_context)
2054                        .map(|(u, safe)| u.input_tokens >= safe * 85 / 100)
2055                        .unwrap_or(false);
2056                    if overflow_likely && overflow_retries < 2 {
2057                        emit_overflow_notice(
2058                            color,
2059                            merged.as_ref(),
2060                            safe_context,
2061                            model,
2062                            overflow_retries + 1,
2063                        );
2064                        // Compress toward 3/4 of the safe window — comfortably
2065                        // under the 85% trigger (was a blunt count trim before
2066                        // Step 18.4). The retry happens regardless: it is
2067                        // already bounded by `overflow_retries`. The target
2068                        // arithmetic stays in real-token space (`safe_context`
2069                        // and the schema overhead are real-token figures),
2070                        // then converts once into the pipeline's chars/4
2071                        // currency (Phase 20 §2.3).
2072                        let target = calibrate_down(
2073                            safe_context
2074                                .map(|s| (s as usize).saturating_mul(3) / 4)
2075                                .unwrap_or(0)
2076                                .saturating_sub(tool_tokens_real),
2077                            cal,
2078                        );
2079                        let outcome = compress(
2080                            CompressRequest {
2081                                messages: &messages,
2082                                budget: target,
2083                                max_messages: None,
2084                                task,
2085                                hard_budget: true,
2086                                // A suspected silent overflow is a real failure
2087                                // signal — refuse semantics apply (Step 20.3).
2088                                authoritative: true,
2089                                focus: None,
2090                                est: estimation,
2091                                summary_input_cap_floor_chars,
2092                                compaction_store,
2093                            },
2094                            summarizer,
2095                            compress_state,
2096                        )
2097                        .await;
2098                        if let Some(notice) = outcome.notice {
2099                            print_harness_notice(&notice, color);
2100                        }
2101                        if outcome.fired {
2102                            messages = outcome.messages;
2103                            prompt_tracker.invalidate();
2104                        } else {
2105                            // N1: the retry must differ from the request that
2106                            // just returned empty — when compress was a no-op
2107                            // (Fit / nothing reclaimable), fall back to one
2108                            // structural prune with a tight protected tail.
2109                            let fallback = crate::prune::prune(
2110                                &messages,
2111                                &crate::prune::PruneConfig {
2112                                    keep_last: 2,
2113                                    ..Default::default()
2114                                },
2115                            );
2116                            if fallback.chars_reclaimed > 0 {
2117                                messages = fallback.messages;
2118                                prompt_tracker.invalidate();
2119                            }
2120                        }
2121                        accumulated_usage = merged;
2122                        overflow_retries += 1;
2123                        continue 'round_loop;
2124                    }
2125                    // Phase 20 §2.2: persistent empties past the retry budget
2126                    // at ≥85% of the safe window are silent-overflow evidence
2127                    // — reported at the exit, with the merged prompt figure,
2128                    // before either return below.
2129                    if overflow_likely {
2130                        if let (Some(hook), Some(u)) =
2131                            (on_round_usage.as_deref_mut(), merged.as_ref())
2132                        {
2133                            hook(RoundObservation::SuspectedOverflow {
2134                                prompt_tokens: u.input_tokens,
2135                            });
2136                        }
2137                    }
2138                    if generated_unusable_output {
2139                        if trace {
2140                            print_trace(&ollama_response_shape(&json), color);
2141                        }
2142                        // Phase 20 §2.2: the diagnostic exit is also a
2143                        // thinking-only detection site (at most once per
2144                        // turn; the function returns right after, so the
2145                        // turn-local flag needs no update here).
2146                        if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2147                            if let Some(hook) = on_round_usage.as_deref_mut() {
2148                                hook(RoundObservation::ThinkingOnly);
2149                            }
2150                        }
2151                        return Ok((
2152                            suspicious_empty_ollama_diagnostic(&json),
2153                            false,
2154                            merged,
2155                            hallucination_count,
2156                        ));
2157                    }
2158                    let msg = "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)";
2159                    return Ok((msg.to_string(), false, merged, hallucination_count));
2160                }
2161                // Use probe content; print it since it was never streamed.
2162                maybe_nudge_no_tool_content!(probe_content.as_str(), stream_usage);
2163                // Phase 20 §2.2: non-empty probe content is usable output.
2164                emit_accepted(
2165                    &mut on_round_usage,
2166                    round_usage,
2167                    truncation_suspect,
2168                    round_est_raw,
2169                );
2170                return Ok((
2171                    probe_content,
2172                    false,
2173                    merge_round_usage(accumulated_usage, stream_usage),
2174                    hallucination_count,
2175                ));
2176            }
2177
2178            // Narrate-then-stop rescue: the model produced prose and no tool
2179            // call. If it has already acted this turn (mid-task) or the prose
2180            // reads as intent-to-act, nudge it to actually call the tool and run
2181            // another round instead of ending the turn — what a human "continue"
2182            // does. Bounded by NARRATION_NUDGE_CAP and the round budget so a
2183            // chronic narrator can't loop; after the cap the prose is accepted
2184            // as the final answer (the return below). A genuine from-the-start
2185            // final answer (no prior call, no intent cue) is never nudged.
2186            maybe_nudge_no_tool_content!(streamed.as_str(), stream_usage);
2187            // Phase 20 §2.2: a non-empty streamed answer is usable output.
2188            emit_accepted(
2189                &mut on_round_usage,
2190                round_usage,
2191                truncation_suspect,
2192                round_est_raw,
2193            );
2194            return Ok((
2195                streamed,
2196                true,
2197                merge_round_usage(accumulated_usage, stream_usage),
2198                hallucination_count,
2199            ));
2200        }
2201
2202        // Has tool calls — add assistant turn and execute them.
2203        // Phase 20 §2.2: tool calls are usable output — the dispatched prompt
2204        // is proven accepted regardless of how the turn later ends.
2205        emit_accepted(
2206            &mut on_round_usage,
2207            round_usage,
2208            truncation_suspect,
2209            round_est_raw,
2210        );
2211        messages.push(message.clone());
2212        let mut round_wrote = false;
2213        let mut round_modified_workspace = false;
2214        let mut round_progress = false;
2215        for tc in tool_calls.unwrap() {
2216            let anthropic_native = tc["function"].is_null();
2217            let name = if anthropic_native {
2218                tc["name"].as_str().unwrap_or("unknown")
2219            } else {
2220                tc["function"]["name"].as_str().unwrap_or("unknown")
2221            };
2222            let args = if anthropic_native {
2223                tc["input"].clone()
2224            } else {
2225                match &tc["function"]["arguments"] {
2226                    serde_json::Value::String(s) => {
2227                        serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
2228                    }
2229                    v => v.clone(),
2230                }
2231            };
2232            if is_hallucination(name, &args) {
2233                hallucination_count += 1;
2234            }
2235            // Step 27.3/#771: short-circuit selected exact repeats — steer
2236            // instead of re-executing a dead or already-useful call. The bogus
2237            // emission is still counted above; we just don't run it again.
2238            if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
2239                if let Some(rec) = tool_events.as_deref_mut() {
2240                    rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
2241                }
2242                messages.push(serde_json::json!({ "role": "tool", "content": steer }));
2243                continue;
2244            }
2245            if !is_read_only_call(name, &args) {
2246                round_wrote = true;
2247            }
2248            // Organic save_note use resets the memory-nudge counter (the
2249            // read-only-rounds reset pattern) — active curators never see it.
2250            if name == "save_note" && note_sink.is_some() {
2251                if let Some(n) = note_nudge.as_deref_mut() {
2252                    n.note_saved();
2253                }
2254            }
2255            // retry technique: snapshot the file's pre-write bytes before the
2256            // write tool runs, so the post-turn gate can revert exactly newt's writes.
2257            ledger_note_write(write_ledger, name, &args, workspace);
2258            let tool_t0 = std::time::Instant::now();
2259            // #727: intercept the read-only budget self-read here. Its answer is
2260            // dynamic per-turn loop state — the num_ctx input ceiling and the
2261            // conversation's token estimate — which are in scope in the loop, not
2262            // inside execute_tool. `prompt_tracker.current` is real-token currency,
2263            // the same the ceiling is in. The rendered string then flows through
2264            // all the normal bookkeeping below (ok, tool_events, phantom_reaches,
2265            // spill), so aliases recorded as Rewrites stay correct.
2266            let result = if tools::is_context_remaining_call(name) {
2267                let report = budget::render_context_budget(
2268                    prompt_tracker.current(&messages, Some(&tools), cal, estimation),
2269                    num_ctx_input_ceiling(num_ctx),
2270                    num_ctx,
2271                );
2272                display::print_tool_call("get_context_remaining", "", color);
2273                display::print_tool_output(&report, tool_output_lines, color);
2274                report
2275            } else {
2276                execute_tool_with_offload(
2277                    name,
2278                    &args,
2279                    workspace,
2280                    color,
2281                    tool_output_lines,
2282                    caveats,
2283                    mcp,
2284                    build_check_cmd.as_deref(),
2285                    // Reborrow + re-coerce: shortens the trait-object lifetime to
2286                    // this call (Option<&mut dyn _> is invariant, so the longer
2287                    // ChatCtx lifetime can't unify directly).
2288                    note_sink
2289                        .as_deref_mut()
2290                        .map(|s| &mut *s as &mut dyn NoteSink),
2291                    recall_source,
2292                    memory_source,
2293                    // #263 prompted grants — same reborrow pattern as note_sink.
2294                    permission_gate
2295                        .as_deref_mut()
2296                        .map(|g| &mut *g as &mut dyn PermissionGate),
2297                    // #307: the active preset's exec floor (the bypass ceiling).
2298                    exec_floor,
2299                    // PR4: the injected embedded-git capability (None for headless).
2300                    git_tool,
2301                    // #479: the injected crew/team orchestration (None for headless).
2302                    crew_runner,
2303                    scratchpad_store,
2304                    code_search,
2305                    experience_store,
2306                    step_ledger,
2307                    tool_offload,
2308                    spill_store,
2309                )
2310                .await
2311            };
2312            // 17.6: record the call for the turn's events column — args are
2313            // digested (never stored raw), duration is a display claim.
2314            // Step 27.3/#771: classify once; remember outcomes that should make
2315            // an exact repeat self-correct next round.
2316            let ok = tools::tool_result_ok(&result);
2317            if ok && is_workspace_write_call(name) {
2318                round_modified_workspace = true;
2319            }
2320            if ok && meaningful_workflow_progress(name, &result) {
2321                round_progress = true;
2322            }
2323            repeat_calls.record(name, &args, ok, &result);
2324            if workflow_runtime.record_tool_result(&result) {
2325                round_progress = true;
2326            }
2327            if let Some(rec) = tool_events.as_deref_mut() {
2328                rec.push(crate::ToolEvent::from_call(
2329                    name,
2330                    &args,
2331                    ok,
2332                    u64::try_from(tool_t0.elapsed().as_millis()).ok(),
2333                ));
2334            }
2335            // #717: record any phantom/capability reach (alias / hallucination
2336            // / real-tool empty miss) for the alias-seam telemetry. #479 (G4)
2337            // composes the gated-off seam here, where `advertise_team` is known:
2338            // a `crew`/`compose_roster` reach with the surface OFF is a real name
2339            // (so `classify_phantom_reach` never flags it) but exactly the
2340            // delegation signal we want to mine for the common OFF default.
2341            if let Some(pr) = phantom_reaches.as_deref_mut() {
2342                if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
2343                    .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
2344                {
2345                    pr.push(crate::PhantomReach {
2346                        name_as_called: name.to_string(),
2347                        resolution,
2348                        active_context_features: Vec::new(),
2349                    });
2350                }
2351            }
2352            // #867 Part A: ledger the verified paths this result surfaced
2353            // BEFORE the offload may spill the text out of the transcript.
2354            observed_paths.record(&result, &observed_resolver);
2355            messages.push(serde_json::json!({
2356                "role": "tool",
2357                // Step 26.3 (#584): offload an oversized result (redact → spill →
2358                // teaser+handle) when tool_offload is on; unchanged otherwise.
2359                "content": maybe_offload_tool_result(name, result, tool_offload, spill_store)
2360            }));
2361        }
2362        if round_wrote {
2363            read_only_rounds = 0;
2364        } else {
2365            read_only_rounds = read_only_rounds.saturating_add(1);
2366        }
2367        workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
2368    }
2369
2370    // Reached the round cap. Trim the bloated message list so the final
2371    // summary request doesn't overflow the model's context window, then
2372    // make ONE tools-disabled completion so the user gets a real partial answer.
2373    let trimmed = trim_for_summary(&messages, 2, 6);
2374    // Step 27.5: salvage the plan/state ledger + the failed-call count so the
2375    // summary reflects progress and the fallback advice is honest.
2376    let progress = cap_exit_progress(step_ledger, scratchpad_store);
2377    let (text, streamed, usage) = final_summary_ollama(
2378        &client,
2379        &chat_url,
2380        model,
2381        trimmed,
2382        CapExit {
2383            max_tool_rounds,
2384            accumulated: accumulated_usage,
2385            wasted_calls: repeat_calls.total_failures(),
2386            progress,
2387            observed: observed_paths.into_vec(),
2388        },
2389    )
2390    .await?;
2391    // #867: the evidence for this summary was just trimmed away, which is
2392    // exactly when a model fabricates plausible file paths — verify every
2393    // cited path against the workspace and append a visible refutation for
2394    // any that don't exist. Appends only; the model's prose is never edited.
2395    let text = claim_check::annotate_against_workspace(text, workspace);
2396    Ok((text, streamed, usage, hallucination_count))
2397}
2398
2399/// Returns `true` when `name` is a tool that doesn't modify the workspace.
2400/// Used to count consecutive read-only rounds and inject a write-nudge.
2401/// `save_note` writes *memory*, not the workspace — a round that only saved
2402/// a note must not suppress the stop-exploring-start-writing nudge; `recall`
2403/// (17.5) reads past conversations and is likewise pure exploration.
2404/// First line of a tool result, capped — used as the remembered error reason in
2405/// [`RepeatCallGuard`] so the steering message is short.
2406fn first_line(s: &str) -> String {
2407    s.lines().next().unwrap_or("").chars().take(200).collect()
2408}
2409
2410/// Per-run guard against a weak model looping on a tool call whose result should
2411/// already be actionable. Step 27.3 covered failures: the forensic session showed
2412/// the model re-issuing the *identical* failed `run_command` three times and
2413/// re-reading the same file ~8×, burning rounds. Later field reports added
2414/// success-shaped loops: no-result probes (`recall`, `state_get`), successful
2415/// `web_fetch` calls with real content, and successful read-only shell probes.
2416///
2417/// Keyed by `(name, canonical args)`, it short-circuits selected exact repeats
2418/// with steering instead of re-executing them. The classifier sees every call
2419/// outcome, but most successes deliberately stay repeatable; only failures,
2420/// success-shaped no-results, successful `web_fetch`, and successful read-only
2421/// shell probes are memoized. It also counts failures per tool name so the steer
2422/// can escalate ("stop using `run_command` — it keeps failing this session; use
2423/// the embedded tools"). This handles ANY persistently-failing tool — a dead
2424/// shell, a denied command, an unimplemented op — without needing to know *why*
2425/// it fails (shell availability is a build/config property with no clean runtime
2426/// signal; see `tools::ocap_disabled` docs).
2427#[derive(Debug, Clone, PartialEq, Eq)]
2428enum RepeatMemo {
2429    Failure {
2430        first_line: String,
2431    },
2432    NoResult {
2433        reason: String,
2434    },
2435    EvidenceObserved {
2436        subject: String,
2437        advice: &'static str,
2438    },
2439}
2440
2441#[derive(Default)]
2442struct RepeatCallGuard {
2443    /// `(name + canonical args)` → the prior outcome that should steer an exact repeat.
2444    repeat_memos: std::collections::HashMap<String, RepeatMemo>,
2445    /// `name` → how many times it has failed this run (any args).
2446    fails_by_tool: std::collections::HashMap<String, usize>,
2447}
2448
2449impl RepeatCallGuard {
2450    /// How many consecutive failures of one tool before the steer escalates to
2451    /// "stop using it".
2452    const ESCALATE_AFTER: usize = 2;
2453
2454    fn key(name: &str, args: &serde_json::Value) -> String {
2455        // The model emits byte-identical args when it loops (confirmed by the
2456        // identical forensic args digests), so the compact JSON is a stable key.
2457        format!("{name}\u{1}{args}")
2458    }
2459
2460    /// Steering message if this exact `(name, args)` already produced a memoized
2461    /// outcome that should not be repeated this run, else `None` (let it execute).
2462    fn repeat_steer(&self, name: &str, args: &serde_json::Value) -> Option<String> {
2463        let key = Self::key(name, args);
2464        match self.repeat_memos.get(&key)? {
2465            RepeatMemo::Failure { first_line: prev } => {
2466                let mut msg = format!(
2467                    "You already called `{name}` with these exact arguments and it failed: {prev}. \
2468                     Do NOT repeat the same call — use a different tool or different arguments."
2469                );
2470                if self.fails_by_tool.get(name).copied().unwrap_or(0) >= Self::ESCALATE_AFTER {
2471                    msg.push_str(&format!(
2472                        " `{name}` has failed repeatedly this session; stop using it and prefer \
2473                         the embedded tools (read_file, edit_file, write_file, find, git)."
2474                    ));
2475                }
2476                Some(msg)
2477            }
2478            RepeatMemo::NoResult { reason } => Some(format!(
2479                "You already ran `{name}` with these exact arguments this turn and {reason}. \
2480                 Don't repeat the identical call — create or update the missing state, change \
2481                 the arguments when the tool accepts them, or use a different tool."
2482            )),
2483            RepeatMemo::EvidenceObserved { subject, advice } => Some(format!(
2484                "You already observed {subject} with `{name}` and received output. Do NOT repeat \
2485                 the identical call — {advice}"
2486            )),
2487        }
2488    }
2489
2490    fn successful_fetch_url(name: &str, args: &serde_json::Value, result: &str) -> Option<String> {
2491        if name != "web_fetch" {
2492            return None;
2493        }
2494        let url = args.get("url")?.as_str()?.trim();
2495        if url.is_empty() || result.trim().is_empty() {
2496            None
2497        } else {
2498            Some(url.chars().take(200).collect())
2499        }
2500    }
2501
2502    fn successful_read_only_shell_command(
2503        name: &str,
2504        args: &serde_json::Value,
2505        result: &str,
2506    ) -> Option<String> {
2507        if name != "run_command" || result.trim().is_empty() {
2508            return None;
2509        }
2510        let command = args.get("command")?.as_str()?.trim();
2511        if is_read_only_shell_probe(command) {
2512            Some(command.chars().take(200).collect())
2513        } else {
2514            None
2515        }
2516    }
2517
2518    /// #718: classify a SUCCESS-shaped result that is empty *by design* — it
2519    /// passes `tool_result_ok` (ok=true) so the failure path never sees it, yet
2520    /// the model loops the identical call. Pure; keyed on the exact result
2521    /// prefixes (`recall.rs` keeps "no matches in past conversations";
2522    /// `scratchpad.rs` returns "no such key: ..."). `None` when the result
2523    /// carries real content (nothing to steer).
2524    fn no_result_reason(name: &str, result: &str) -> Option<&'static str> {
2525        match name {
2526            "recall" if result.starts_with("no matches in past conversations") => Some(
2527                "it returned no matches (and recall cannot see the current conversation \
2528                 — use resume_context for THIS conversation)",
2529            ),
2530            "state_get" if result.starts_with("no such key") => {
2531                Some("the key is not set (state_get returned \"no such key\")")
2532            }
2533            "plan_get" if result.starts_with("no active plan") => Some(
2534                "it found no active plan; call update_plan now with a short ordered plan if the \
2535                 work has more than one step",
2536            ),
2537            _ => None,
2538        }
2539    }
2540
2541    /// Classify a just-executed call into the subset of outcomes that should
2542    /// steer an exact repeat. This function sees all calls, but deliberately
2543    /// returns `None` for ordinary successes so valid repeated work (builds,
2544    /// tests, rereads after edits, write-capable commands) can keep running.
2545    fn classify_repeat_memo(
2546        name: &str,
2547        args: &serde_json::Value,
2548        ok: bool,
2549        result: &str,
2550    ) -> Option<RepeatMemo> {
2551        if !ok {
2552            return Some(RepeatMemo::Failure {
2553                first_line: first_line(result),
2554            });
2555        }
2556        if let Some(reason) = Self::no_result_reason(name, result) {
2557            return Some(RepeatMemo::NoResult {
2558                reason: reason.to_string(),
2559            });
2560        }
2561        if let Some(url) = Self::successful_fetch_url(name, args, result) {
2562            return Some(RepeatMemo::EvidenceObserved {
2563                subject: format!("`{url}`"),
2564                advice: "use the fetched content above, fetch a different URL, inspect local \
2565                         files, or answer the user.",
2566            });
2567        }
2568        if let Some(command) = Self::successful_read_only_shell_command(name, args, result) {
2569            return Some(RepeatMemo::EvidenceObserved {
2570                subject: format!("read-only shell probe `{command}`"),
2571                advice: "use the observed output above, change the query, inspect a different \
2572                         file, or make the next edit/test decision.",
2573            });
2574        }
2575        None
2576    }
2577
2578    /// Record a just-executed call's outcome. Failures are also counted so the
2579    /// steer can escalate; success-shaped memos are not counted because they are
2580    /// not hard failures.
2581    fn record(&mut self, name: &str, args: &serde_json::Value, ok: bool, result: &str) {
2582        if !ok {
2583            *self.fails_by_tool.entry(name.to_string()).or_default() += 1;
2584        }
2585        if let Some(memo) = Self::classify_repeat_memo(name, args, ok, result) {
2586            self.repeat_memos.insert(Self::key(name, args), memo);
2587        }
2588    }
2589
2590    /// Total failed tool executions this run (across all tools) — a signal that
2591    /// a cap exit was thrash, not lack of rounds (Step 27.5).
2592    fn total_failures(&self) -> usize {
2593        self.fails_by_tool.values().sum()
2594    }
2595}
2596
2597#[derive(Debug, Clone, PartialEq, Eq)]
2598struct WorkflowErrorEvidence {
2599    fingerprint: String,
2600    observations: usize,
2601}
2602
2603#[derive(Debug, Default)]
2604struct WorkflowRuntimeState {
2605    error_evidence: Option<WorkflowErrorEvidence>,
2606    read_only_rounds_after_evidence: usize,
2607    writes_after_evidence: usize,
2608    rounds_since_progress: Option<usize>,
2609    /// Override for [`WORKFLOW_RECENT_PROGRESS_ROUNDS`], set once per turn from
2610    /// the matching [`crate::WorkflowSteerer`] workflow's
2611    /// `progress_horizon_rounds` (diagnostic workflows legitimately need more
2612    /// read-only rounds between plan/edit checkpoints than routine edits do —
2613    /// see `diagnose_failure.toml`). `None` uses the shared default.
2614    progress_horizon_rounds: Option<usize>,
2615    step_lock_nudges: usize,
2616    rediscovery_nudges: usize,
2617}
2618
2619impl WorkflowRuntimeState {
2620    const STEP_LOCK_NUDGE_CAP: usize = 3;
2621    const REDISCOVERY_NUDGE_CAP: usize = 2;
2622
2623    /// Set once per turn from the matching workflow's horizon override, if
2624    /// any. A no-op call (`None`) leaves the shared default in effect.
2625    fn set_progress_horizon(&mut self, rounds: Option<usize>) {
2626        self.progress_horizon_rounds = rounds;
2627    }
2628
2629    fn progress_horizon(&self) -> usize {
2630        self.progress_horizon_rounds
2631            .unwrap_or(WORKFLOW_RECENT_PROGRESS_ROUNDS)
2632    }
2633
2634    fn record_tool_result(&mut self, result: &str) -> bool {
2635        let Some(fingerprint) = workflow_error_fingerprint(result) else {
2636            return false;
2637        };
2638        match self.error_evidence.as_mut() {
2639            Some(evidence) if evidence.fingerprint == fingerprint => {
2640                evidence.observations = evidence.observations.saturating_add(1);
2641                false
2642            }
2643            _ => {
2644                self.error_evidence = Some(WorkflowErrorEvidence {
2645                    fingerprint,
2646                    observations: 1,
2647                });
2648                self.read_only_rounds_after_evidence = 0;
2649                self.writes_after_evidence = 0;
2650                self.step_lock_nudges = 0;
2651                self.rediscovery_nudges = 0;
2652                true
2653            }
2654        }
2655    }
2656
2657    fn record_round_outcome(&mut self, round_wrote: bool, round_progress: bool) {
2658        if round_progress {
2659            self.rounds_since_progress = Some(0);
2660        } else if let Some(rounds) = self.rounds_since_progress.as_mut() {
2661            *rounds = rounds.saturating_add(1);
2662        }
2663        if self.error_evidence.is_none() {
2664            return;
2665        }
2666        if round_wrote {
2667            self.writes_after_evidence = self.writes_after_evidence.saturating_add(1);
2668            self.read_only_rounds_after_evidence = 0;
2669        } else {
2670            self.read_only_rounds_after_evidence =
2671                self.read_only_rounds_after_evidence.saturating_add(1);
2672        }
2673    }
2674
2675    fn round_start_nudge(
2676        &mut self,
2677        step_ledger: Option<&dyn scheduled::StepLedger>,
2678    ) -> Option<String> {
2679        let evidence = self.error_evidence.as_ref()?;
2680        if self.writes_after_evidence > 0 || self.read_only_rounds_after_evidence == 0 {
2681            return None;
2682        }
2683        if self.step_lock_nudges >= Self::STEP_LOCK_NUDGE_CAP {
2684            return None;
2685        }
2686        self.step_lock_nudges += 1;
2687        Some(workflow_step_lock_nudge(
2688            &evidence.fingerprint,
2689            evidence.observations,
2690            active_step_description(step_ledger).as_deref(),
2691        ))
2692    }
2693
2694    fn rediscovery_nudge(
2695        &mut self,
2696        classification: Option<&crate::NudgeClassification>,
2697        content: &str,
2698        step_ledger: Option<&dyn scheduled::StepLedger>,
2699    ) -> Option<String> {
2700        let evidence = self.error_evidence.as_ref()?;
2701        if self.writes_after_evidence > 0 {
2702            return None;
2703        }
2704        if self.rediscovery_nudges >= Self::REDISCOVERY_NUDGE_CAP {
2705            return None;
2706        }
2707        let classified_stall = classification.is_some_and(|c| {
2708            matches!(
2709                c.class,
2710                crate::NudgeClass::PendingAction | crate::NudgeClass::PlanUpdate
2711            )
2712        });
2713        if !classified_stall && !looks_like_error_rediscovery(content) {
2714            return None;
2715        }
2716        self.rediscovery_nudges += 1;
2717        Some(workflow_rediscovery_nudge(
2718            &evidence.fingerprint,
2719            active_step_description(step_ledger).as_deref(),
2720        ))
2721    }
2722
2723    fn cap_grace_nudge(
2724        &mut self,
2725        step_ledger: Option<&dyn scheduled::StepLedger>,
2726        max_tool_rounds: usize,
2727        workflow_grace_rounds: usize,
2728    ) -> Option<String> {
2729        if workflow_grace_rounds == 0 {
2730            return None;
2731        }
2732        let active_step = active_step_description(step_ledger);
2733        let recent_progress = self
2734            .rounds_since_progress
2735            .is_some_and(|rounds| rounds <= self.progress_horizon());
2736        if let Some(evidence) = self.error_evidence.as_ref() {
2737            if self.writes_after_evidence > 0 {
2738                return Some(workflow_post_write_grace_nudge(
2739                    &evidence.fingerprint,
2740                    active_step.as_deref(),
2741                    max_tool_rounds,
2742                    workflow_grace_rounds,
2743                ));
2744            }
2745            if self.read_only_rounds_after_evidence > 0 || recent_progress {
2746                return Some(workflow_cap_grace_nudge(
2747                    &evidence.fingerprint,
2748                    active_step.as_deref(),
2749                    max_tool_rounds,
2750                    workflow_grace_rounds,
2751                ));
2752            }
2753        }
2754        if active_step.is_some() && recent_progress {
2755            return Some(workflow_progress_grace_nudge(
2756                active_step.as_deref(),
2757                max_tool_rounds,
2758                workflow_grace_rounds,
2759            ));
2760        }
2761        None
2762    }
2763}
2764
2765fn workflow_error_fingerprint(result: &str) -> Option<String> {
2766    build_error_fingerprint(result).or_else(|| edit_miss_fingerprint(result))
2767}
2768
2769fn build_error_fingerprint(result: &str) -> Option<String> {
2770    let mut pending_error: Option<String> = None;
2771    let mut fingerprints = Vec::new();
2772    for line in result.lines() {
2773        let trimmed = line.trim();
2774        if trimmed.starts_with("error[") || trimmed.starts_with("error:") {
2775            pending_error = Some(normalize_error_line(trimmed));
2776            continue;
2777        }
2778        if let Some(rest) = trimmed.strip_prefix("-->") {
2779            if let Some(error) = pending_error.take() {
2780                let location = rest
2781                    .split_whitespace()
2782                    .next()
2783                    .unwrap_or("")
2784                    .trim()
2785                    .trim_start_matches("./");
2786                if location.is_empty() {
2787                    fingerprints.push(error);
2788                } else {
2789                    fingerprints.push(format!("{location} {error}"));
2790                }
2791                if fingerprints.len() >= 3 {
2792                    break;
2793                }
2794            }
2795        }
2796    }
2797    if fingerprints.is_empty() {
2798        pending_error.map(|e| e.chars().take(240).collect())
2799    } else {
2800        Some(fingerprints.join(" | ").chars().take(500).collect())
2801    }
2802}
2803
2804fn edit_miss_fingerprint(result: &str) -> Option<String> {
2805    let lc = result.to_ascii_lowercase();
2806    if !(lc.contains("old_string")
2807        && (lc.contains("not found")
2808            || lc.contains("old string not found")
2809            || lc.contains("matches 0")
2810            || lc.contains("no match")))
2811    {
2812        return None;
2813    }
2814    let line = result
2815        .lines()
2816        .find(|line| {
2817            let l = line.to_ascii_lowercase();
2818            l.contains("old_string")
2819                && (l.contains("not found") || l.contains("matches 0") || l.contains("no match"))
2820        })
2821        .unwrap_or("edit_file old_string not found");
2822    Some(format!("edit_file {}", normalize_error_line(line)))
2823}
2824
2825fn normalize_error_line(line: &str) -> String {
2826    let mut out = String::new();
2827    let mut in_ws = false;
2828    for c in line.chars() {
2829        if c.is_whitespace() {
2830            if !in_ws {
2831                out.push(' ');
2832                in_ws = true;
2833            }
2834        } else if c != '`' {
2835            out.push(c);
2836            in_ws = false;
2837        }
2838    }
2839    out.chars().take(240).collect()
2840}
2841
2842fn active_step_description(step_ledger: Option<&dyn scheduled::StepLedger>) -> Option<String> {
2843    let snapshot = step_ledger?.snapshot();
2844    snapshot
2845        .steps
2846        .iter()
2847        .find(|step| step.status == StepStatus::Active)
2848        .or_else(|| {
2849            snapshot
2850                .steps
2851                .iter()
2852                .find(|step| step.status != StepStatus::Done)
2853        })
2854        .map(|step| step.description.clone())
2855}
2856
2857fn workflow_step_lock_nudge(
2858    fingerprint: &str,
2859    observations: usize,
2860    active_step: Option<&str>,
2861) -> String {
2862    let active = active_step
2863        .map(|step| format!(" Active step: '{step}'."))
2864        .unwrap_or_default();
2865    format!(
2866        "<workflow_state>\nactive_step = \"repair the current tool/build error\"\nlast_error_fingerprint = \"{fingerprint}\"\nobservations = {observations}\nnext_allowed_actions = \"use the latest file evidence, then edit_file/write_file for the active repair, then run the focused verification\"\ndisallowed_actions = \"re-reading the same evidence, re-deriving the same plan, or restating findings without editing\"\n</workflow_state>\n{active} You already have the error evidence above. Do not re-read or summarize it again unless you need one exact replacement span. Make the smallest edit that addresses this exact fingerprint, then run the focused check."
2867    )
2868}
2869
2870fn workflow_rediscovery_nudge(fingerprint: &str, active_step: Option<&str>) -> String {
2871    let active = active_step
2872        .map(|step| format!(" Active step: '{step}'."))
2873        .unwrap_or_default();
2874    format!(
2875        "You are rediscovering an error that is already recorded: {fingerprint}.{active} Do not restate findings, update the same plan, or claim handoff. Call the concrete edit tool for this repair now. After the edit, run one focused verification command and use its new output as ground truth."
2876    )
2877}
2878
2879fn workflow_cap_grace_nudge(
2880    fingerprint: &str,
2881    active_step: Option<&str>,
2882    max_tool_rounds: usize,
2883    workflow_grace_rounds: usize,
2884) -> String {
2885    let active = active_step
2886        .map(|step| format!(" Active step: '{step}'."))
2887        .unwrap_or_default();
2888    format!(
2889        "<workflow_state>\nnormal_tool_round_cap = {max_tool_rounds}\nconfigured_workflow_grace_rounds = {workflow_grace_rounds}\nlast_error_fingerprint = \"{fingerprint}\"\nnext_allowed_actions = \"call edit_file or write_file now using the latest observed file contents; then run the focused verification\"\ndisallowed_actions = \"summary of findings, handoff, plan rediscovery, or another broad read-only pass\"\n</workflow_state>\nThe normal tool-call cap was reached immediately after repair evidence without a successful workspace edit.{active} This is a bounded grace window, not a final-answer round. Use the latest observed contents and call the concrete edit tool now. If one exact replacement span is still missing, read only that minimal span, then edit in the grace window."
2890    )
2891}
2892
2893fn workflow_post_write_grace_nudge(
2894    fingerprint: &str,
2895    active_step: Option<&str>,
2896    max_tool_rounds: usize,
2897    workflow_grace_rounds: usize,
2898) -> String {
2899    let active = active_step
2900        .map(|step| format!(" Active step: '{step}'."))
2901        .unwrap_or_default();
2902    format!(
2903        "<workflow_state>\nnormal_tool_round_cap = {max_tool_rounds}\nconfigured_workflow_grace_rounds = {workflow_grace_rounds}\nlast_error_fingerprint = \"{fingerprint}\"\nnext_allowed_actions = \"run the focused verification for the edit you just made, or continue the active implementation step with one concrete tool call\"\ndisallowed_actions = \"summary of findings, handoff, or broad rediscovery\"\n</workflow_state>\nThe normal tool-call cap was reached immediately after a workspace edit related to recorded repair evidence.{active} This is a bounded verification window. Do not summarize or stop because of the normal cap; run the focused check or the next concrete implementation tool now."
2904    )
2905}
2906
2907fn workflow_progress_grace_nudge(
2908    active_step: Option<&str>,
2909    max_tool_rounds: usize,
2910    workflow_grace_rounds: usize,
2911) -> String {
2912    let active = active_step
2913        .map(|step| format!(" Active step: '{step}'."))
2914        .unwrap_or_default();
2915    format!(
2916        "<workflow_state>\nnormal_tool_round_cap = {max_tool_rounds}\nconfigured_workflow_grace_rounds = {workflow_grace_rounds}\nnext_allowed_actions = \"continue the active workflow step with one concrete tool call, then update the plan or verify\"\ndisallowed_actions = \"summary of findings, handoff, or broad rediscovery\"\n</workflow_state>\nThe normal tool-call cap was reached while the active workflow was still making concrete progress.{active} This is a bounded grace window, not a final-answer round. Continue with the next concrete implementation or verification tool now."
2917    )
2918}
2919
2920fn looks_like_error_rediscovery(content: &str) -> bool {
2921    let lc = content.to_ascii_lowercase();
2922    (lc.contains("summary of findings")
2923        || lc.contains("root cause")
2924        || lc.contains("current state")
2925        || lc.contains("remaining work")
2926        || lc.contains("build failure"))
2927        && (lc.contains("error") || lc.contains("build") || lc.contains("compile"))
2928}
2929
2930/// Render the agent's working-memory progress (`<plan>` checklist + `<state>`)
2931/// at a cap exit, so partial work is salvaged into the final summary / fallback
2932/// instead of being lost (Step 27.5). `None` when both are empty.
2933fn cap_exit_progress(
2934    step_ledger: Option<&dyn scheduled::StepLedger>,
2935    scratchpad_store: Option<&dyn scratchpad::ScratchpadStore>,
2936) -> Option<String> {
2937    let plan = step_ledger.and_then(scheduled::plan_block);
2938    let state = scratchpad_store.and_then(scratchpad::scratchpad_state_block);
2939    let parts: Vec<String> = [plan, state].into_iter().flatten().collect();
2940    (!parts.is_empty()).then(|| parts.join("\n\n"))
2941}
2942
2943fn is_read_only_tool(name: &str) -> bool {
2944    matches!(
2945        name,
2946        "list_dir"
2947            | "read_file"
2948            | "find"
2949            | "search"
2950            | "web_fetch"
2951            | "use_skill"
2952            | "save_note"
2953            | "recall"
2954    )
2955}
2956
2957fn is_read_only_call(name: &str, args: &serde_json::Value) -> bool {
2958    is_read_only_tool(name)
2959        || (name == "run_command"
2960            && args
2961                .get("command")
2962                .and_then(|v| v.as_str())
2963                .is_some_and(is_read_only_shell_probe))
2964}
2965
2966fn is_workspace_write_call(name: &str) -> bool {
2967    matches!(name, "write_file" | "edit_file")
2968}
2969
2970fn maybe_offload_tool_result(
2971    name: &str,
2972    result: String,
2973    tool_offload: bool,
2974    spill_store: Option<&dyn spill::SpillStore>,
2975) -> String {
2976    if matches!(name, "run_command" | "lifecycle") {
2977        result
2978    } else {
2979        spill::maybe_offload(result, tool_offload, spill_store)
2980    }
2981}
2982
2983fn meaningful_workflow_progress(name: &str, result: &str) -> bool {
2984    match name {
2985        "update_plan" => true,
2986        "write_file" => result.starts_with("wrote ") || result.starts_with("✓ wrote "),
2987        "edit_file" => edit_result_changed_file(result),
2988        _ => false,
2989    }
2990}
2991
2992fn edit_result_changed_file(result: &str) -> bool {
2993    result.starts_with("edited ") || result.starts_with("✓ edited ")
2994}
2995
2996fn is_read_only_shell_probe(command: &str) -> bool {
2997    let command = command.trim();
2998    if command.is_empty() {
2999        return false;
3000    }
3001    const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
3002    if command.contains(SHELL_META) {
3003        return false;
3004    }
3005    let mut tokens = command.split_ascii_whitespace();
3006    let Some(program) = tokens.next() else {
3007        return false;
3008    };
3009    match program {
3010        "grep" | "rg" | "head" | "tail" | "wc" | "pwd" => true,
3011        "sed" => !tokens.any(|t| t == "-i" || t.starts_with("-i")),
3012        _ => false,
3013    }
3014}
3015
3016/// Max "you narrated intent but called no tool" auto-continue nudges per turn.
3017/// Bounded so a chronically-narrating weak model can't loop or drain the round
3018/// budget; after the cap its narration is accepted as the final answer.
3019/// (Candidate for a `[tui] narration_nudge_cap` knob in a follow-up.)
3020const NARRATION_NUDGE_CAP: usize = 1;
3021/// Max "you ended while a plan still has open steps" nudges per turn. This is
3022/// state-driven from the plan ledger, not prose-matched.
3023const PENDING_PLAN_NUDGE_CAP: usize = 1;
3024/// Max "generated hidden thinking but no visible content/tool call" retries per
3025/// turn. The first retry is generic; the second is explicit that hidden
3026/// thinking is not an action. Bounded so a broken backend still exits with the
3027/// diagnostic.
3028const SUSPICIOUS_EMPTY_RETRY_CAP: u32 = 2;
3029/// Max "you claimed file context changed under you without verifying" nudges
3030/// per turn. Kept separate from narration nudges: this is a blocker-specific
3031/// ground-truth check, not generic intent-to-act recovery.
3032const STALE_FILE_NUDGE_CAP: usize = 1;
3033/// Recent-progress horizon used to decide whether a configured workflow grace
3034/// window should activate at the normal round cap.
3035const WORKFLOW_RECENT_PROGRESS_ROUNDS: usize = 3;
3036
3037fn tail_on_char_boundary(s: &str, max_bytes: usize) -> &str {
3038    let cut = s.len().saturating_sub(max_bytes);
3039    let start = (cut..=s.len())
3040        .find(|&i| s.is_char_boundary(i))
3041        .unwrap_or(0);
3042    &s[start..]
3043}
3044
3045/// Compatibility wrapper for the narrate-then-stop classifier. Production turns
3046/// use [`crate::NudgeClassifier::load_default`] so `~/.newt/classifiers/nudge.toml`
3047/// can tune the examples; pure tests use the built-in prototypes here.
3048#[cfg(test)]
3049fn looks_like_intent_to_act(content: &str) -> bool {
3050    crate::NudgeClassifier::builtin().is_pending_action(content)
3051}
3052
3053/// Heuristic: did the model stop because it *believes* a file changed under it,
3054/// without first proving that via git/filesystem ground truth? This catches the
3055/// "stale line numbers ⇒ operator should restore the file" stall: a context
3056/// summary or partial read gets mistaken for a concurrent edit, and the model
3057/// stops instead of running read-only checks.
3058fn looks_like_unverified_stale_file_blocker(content: &str) -> bool {
3059    const FILE_CUES: &[&str] = &[
3060        "file",
3061        "line reference",
3062        "line references",
3063        "old_string",
3064        "edit_file",
3065        ".rs",
3066        ".toml",
3067        ".md",
3068    ];
3069    const STALE_CUES: &[&str] = &[
3070        "modified out from under",
3071        "changed out from under",
3072        "edited out from under",
3073        "modified concurrently",
3074        "changed concurrently",
3075        "stale context",
3076        "contexts are stale",
3077        "context is stale",
3078        "old line references",
3079        "line references are invalid",
3080        "file grew from",
3081        "grew from",
3082    ];
3083    const BLOCKER_CUES: &[&str] = &[
3084        "blocked",
3085        "cannot safely",
3086        "can't safely",
3087        "could land in the wrong place",
3088        "corrupt the code",
3089        "restore",
3090        "git checkout",
3091        "revert",
3092        "operator should",
3093        "human should",
3094        "recommendation",
3095    ];
3096
3097    let lc = content.to_lowercase();
3098    let tail = tail_on_char_boundary(&lc, 1_200);
3099    FILE_CUES.iter().any(|c| tail.contains(c))
3100        && STALE_CUES.iter().any(|c| tail.contains(c))
3101        && BLOCKER_CUES.iter().any(|c| tail.contains(c))
3102}
3103
3104/// The corrective injected when the model narrated its next action but emitted
3105/// no tool call (the narrate-then-stop stall). Sibling of [`read_only_action_nudge`].
3106fn narration_action_nudge() -> String {
3107    "You described what you were about to do but did not call any tool, so \
3108     nothing actually happened. If you intended to act, emit the tool call now \
3109     (for example edit_file or write_file with the real arguments) — do not just \
3110     describe it. If you are genuinely finished, say so explicitly in one \
3111     sentence."
3112        .to_string()
3113}
3114
3115fn stale_file_ground_truth_nudge() -> String {
3116    "You claimed the file changed under you or that your edit context is stale, \
3117     but you did not prove that with ground truth. Before stopping or asking the \
3118     operator to restore/revert anything, run read-only verification: git status \
3119     --short, git diff -- <file>, wc -l <file>, and re-read the exact target \
3120     range. If those checks do not prove an actual concurrent change, continue \
3121     from the verified file contents. Never recommend git checkout/revert unless \
3122     git diff proves unwanted changes and the operator approves."
3123        .to_string()
3124}
3125
3126fn workflow_classifier_text(messages: &[serde_json::Value], current_content: &str) -> String {
3127    let mut parts = Vec::new();
3128    let start = messages.len().saturating_sub(12);
3129    for message in &messages[start..] {
3130        if let Some(text) = message_text(message) {
3131            if !text.trim().is_empty() {
3132                parts.push(text);
3133            }
3134        }
3135    }
3136    if !current_content.trim().is_empty() {
3137        parts.push(current_content.to_string());
3138    }
3139    parts.join("\n")
3140}
3141
3142fn combine_nudge_hints(first: Option<&str>, second: Option<&str>) -> Option<String> {
3143    let text = [first, second]
3144        .into_iter()
3145        .flatten()
3146        .map(str::trim)
3147        .filter(|hint| !hint.is_empty())
3148        .collect::<Vec<_>>()
3149        .join("\n\n");
3150    (!text.is_empty()).then_some(text)
3151}
3152
3153fn message_text(message: &serde_json::Value) -> Option<String> {
3154    match message.get("content")? {
3155        serde_json::Value::String(s) => Some(s.clone()),
3156        serde_json::Value::Array(parts) => {
3157            let text = parts
3158                .iter()
3159                .filter_map(|part| part.get("text").and_then(|text| text.as_str()))
3160                .collect::<Vec<_>>()
3161                .join("\n");
3162            (!text.is_empty()).then_some(text)
3163        }
3164        _ => None,
3165    }
3166}
3167
3168fn pending_plan_completion_nudge(
3169    step_ledger: Option<&dyn scheduled::StepLedger>,
3170    needs_plan_update: bool,
3171    workflow_hint: Option<&str>,
3172) -> Option<String> {
3173    let snapshot = step_ledger?.snapshot();
3174    let total = snapshot.steps.len();
3175    if total == 0 {
3176        return None;
3177    }
3178    let unfinished = snapshot
3179        .steps
3180        .iter()
3181        .filter(|s| s.status != StepStatus::Done)
3182        .count();
3183    if unfinished == 0 {
3184        return None;
3185    }
3186    let active = snapshot
3187        .steps
3188        .iter()
3189        .find(|s| s.status == StepStatus::Active)
3190        .or_else(|| snapshot.steps.iter().find(|s| s.status != StepStatus::Done));
3191    let active_clause = active
3192        .map(|s| format!(" Active step: '{}'.", s.description))
3193        .unwrap_or_default();
3194    let step_word = if unfinished == 1 { "step" } else { "steps" };
3195    let workflow_clause = workflow_hint
3196        .map(str::trim)
3197        .filter(|hint| !hint.is_empty())
3198        .map(|hint| format!("\n\n{hint}"))
3199        .unwrap_or_default();
3200    if needs_plan_update {
3201        Some(format!(
3202            "You ended with a findings/next-steps summary while the active plan still has \
3203             {unfinished}/{total} unfinished {step_word}.{active_clause} Your summary says \
3204             immediate prerequisite repair work now blocks the active step. Call update_plan now \
3205             with the full ordered plan: mark completed steps completed, make the immediate \
3206             blocker repair the active step, and keep later feature work pending. Then call the \
3207             next concrete tool for that active repair. Do not repeat the findings summary or \
3208             claim a tool-call limit while this nudge is giving you another round.{workflow_clause}"
3209        ))
3210    } else {
3211        Some(format!(
3212            "You ended the turn while the active plan still has {unfinished}/{total} unfinished \
3213             {step_word}.{active_clause} Either call update_plan with completed steps marked \
3214             completed, call the next tool for the active step, or state the concrete blocker. \
3215             Do not hand off by only describing remaining work."
3216        ))
3217    }
3218}
3219
3220fn read_only_action_nudge(
3221    read_only_rounds: usize,
3222    remaining_rounds: usize,
3223    step_ledger: Option<&dyn scheduled::StepLedger>,
3224    delegate_hint: Option<&str>,
3225) -> String {
3226    let plan_clause = if step_ledger.and_then(plan_reseat_pointer).is_some() {
3227        " You have an active multi-step plan; keep working the ACTIVE step instead of \
3228         restarting or re-planning."
3229    } else {
3230        ""
3231    };
3232    let delegate_clause = delegate_hint
3233        .map(|hint| format!(" {hint}"))
3234        .unwrap_or_default();
3235    format!(
3236        "[{read_only_rounds} read-only rounds so far. Stop AIMLESS exploring and start \
3237         making the change. This is a nudge, not a limit — you may still read, but if \
3238         you have enough context, call edit_file or write_file now. If a capability \
3239         denial blocks you, call request_permissions with the exact capability and \
3240         target, or take a different approach. If you truly cannot edit yet, state the \
3241         exact blocker. Before edit_file, read the ONE file you are about to change so \
3242         old_string matches exact text; never guess old_string or repeat a failed edit.\
3243         {plan_clause}{delegate_clause} ~{remaining_rounds} round(s) left.]"
3244    )
3245}
3246
3247/// Append the memory-nudge line to the current user message — the last
3248/// message in the list per the memory-manager contract. Defensive fallback:
3249/// if the last message somehow isn't a user turn, push a standalone user
3250/// message instead (mirrors the read-only-rounds nudge injection).
3251fn append_nudge_line(messages: &mut Vec<serde_json::Value>, line: &str) {
3252    match messages.last_mut() {
3253        Some(last) if last["role"] == "user" => {
3254            let cur = last["content"].as_str().unwrap_or_default();
3255            last["content"] = serde_json::Value::String(format!("{cur}\n\n{line}"));
3256        }
3257        _ => messages.push(serde_json::json!({"role": "user", "content": line})),
3258    }
3259}
3260
3261fn ollama_non_content_fields(json: &serde_json::Value) -> Vec<&'static str> {
3262    let message = &json["message"];
3263    ["reasoning", "reasoning_content", "thinking"]
3264        .into_iter()
3265        .filter(|field| {
3266            message[*field]
3267                .as_str()
3268                .map(|value| !value.trim().is_empty())
3269                .unwrap_or(false)
3270        })
3271        .collect()
3272}
3273
3274fn ollama_response_shape(json: &serde_json::Value) -> String {
3275    let message = &json["message"];
3276    let message_keys = message
3277        .as_object()
3278        .map(|obj| obj.keys().cloned().collect::<Vec<_>>().join(","))
3279        .unwrap_or_else(|| "<missing>".to_string());
3280    let content_chars = message["content"]
3281        .as_str()
3282        .map(|content| content.chars().count())
3283        .unwrap_or(0);
3284    let tool_calls = message["tool_calls"]
3285        .as_array()
3286        .map(|calls| calls.len())
3287        .unwrap_or(0);
3288    let non_content = ollama_non_content_fields(json);
3289    let non_content = if non_content.is_empty() {
3290        "none".to_string()
3291    } else {
3292        non_content.join(",")
3293    };
3294    format!(
3295        "ollama response shape: message_keys=[{message_keys}] content_chars={content_chars} tool_calls={tool_calls} non_content_fields=[{non_content}] prompt_eval_count={} eval_count={}",
3296        json["prompt_eval_count"]
3297            .as_u64()
3298            .map_or("missing".to_string(), |n| n.to_string()),
3299        json["eval_count"]
3300            .as_u64()
3301            .map_or("missing".to_string(), |n| n.to_string())
3302    )
3303}
3304
3305fn suspicious_empty_retry_nudge(retry_index: u32, json: &serde_json::Value) -> String {
3306    if retry_index == 0 {
3307        return "Your previous response produced generated tokens but no assistant-visible content \
3308                and no tool call. Reply with either a tool call or final assistant content."
3309            .to_string();
3310    }
3311    let fields = ollama_non_content_fields(json);
3312    let field_note = if fields.is_empty() {
3313        "hidden/non-content fields".to_string()
3314    } else {
3315        format!("hidden/non-content field(s): {}", fields.join(", "))
3316    };
3317    format!(
3318        "Your previous response again produced generated tokens only in {field_note}, \
3319         with no assistant-visible content and no tool call. Hidden thinking is not an \
3320         action. If you intend to act, emit the exact tool call now; otherwise reply \
3321         with final assistant-visible content. Do not continue with hidden-only reasoning."
3322    )
3323}
3324
3325fn suspicious_empty_ollama_diagnostic(json: &serde_json::Value) -> String {
3326    let fields = ollama_non_content_fields(json);
3327    let field_note = if fields.is_empty() {
3328        "no known non-content fields were present".to_string()
3329    } else {
3330        format!("non-content field(s) present: {}", fields.join(", "))
3331    };
3332    format!(
3333        "(model generated output tokens but returned no assistant-visible content or tool calls; {field_note}; rerun with `newt --trace` to capture the response shape)"
3334    )
3335}
3336
3337/// Build the nudge appended to the message list when the tool-round cap is hit.
3338/// `progress` (the `<plan>`/`<state>` working memory, Step 27.5) is folded in so
3339/// the model summarizes against what it actually accomplished; `observed`
3340/// (#867 Part A) is the verified-paths manifest collected across the rounds.
3341fn cap_exit_nudge(max_tool_rounds: usize, progress: Option<&str>, observed: &[String]) -> String {
3342    // #867: the message list was just trimmed (`trim_for_summary`), so most
3343    // of the evidence this summary should cite is GONE — the forensic session
3344    // showed a model reconstructing plausible-but-nonexistent file paths from
3345    // its priors at exactly this point. Constrain the summary to what is
3346    // still verbatim in context; absence must be stated, not papered over.
3347    let mut nudge = format!(
3348        "You have reached the tool-call limit ({max_tool_rounds} rounds). \
3349         Do NOT call any more tools. Summarize what you found across the tool \
3350         calls above and give your best final answer now. Cite only file paths \
3351         that appear verbatim in the messages above — if the evidence you need \
3352         was in the omitted messages, say so plainly instead of reconstructing \
3353         file names or line numbers from memory. Do not answer with an intention \
3354         to keep working (for example, \"let me read/edit/verify\"); if work remains, \
3355         list it as remaining work and state that the round cap stopped further tool calls."
3356    );
3357    // #867 Part A: the ledger survived the trim — hand the model the REAL
3358    // manifest so grounded citation is possible, not just demanded.
3359    if !observed.is_empty() {
3360        nudge.push_str(
3361            "\n\nFile paths actually observed in tool results this run \
3362             (these exist — cite from this list):",
3363        );
3364        for p in observed {
3365            nudge.push_str("\n- ");
3366            nudge.push_str(p);
3367        }
3368    }
3369    if let Some(p) = progress {
3370        nudge.push_str(&format!("\n\nYour progress so far:\n{p}"));
3371    }
3372    nudge
3373}
3374
3375/// Fallback message returned when even the final tools-disabled completion
3376/// fails. Includes accumulated token counts, salvages the `<plan>`/`<state>`
3377/// progress so partial work survives (Step 27.5), and gives HONEST advice: a run
3378/// dominated by failed tool calls is a tooling/permissions problem, not too few
3379/// rounds, so we don't blindly tell the user to raise the cap.
3380fn cap_exit_tokens_hint(max_tool_rounds: usize, accumulated: Option<crate::TokenUsage>) -> String {
3381    match accumulated {
3382        Some(u) => format!(
3383            " ({} in / {} out tokens consumed across {max_tool_rounds} rounds)",
3384            u.input_tokens, u.output_tokens,
3385        ),
3386        None => String::new(),
3387    }
3388}
3389
3390fn cap_exit_advice(max_tool_rounds: usize, wasted_calls: usize) -> &'static str {
3391    // If at least one failed tool call per round, the cap was thrash, not a
3392    // genuine need for more rounds.
3393    if wasted_calls >= max_tool_rounds.max(1) {
3394        "most of those rounds were spent on tool calls that failed — the model \
3395         could not find a working edit/shell path, which is usually a tooling or \
3396         permissions issue rather than too few rounds; check `newt doctor`"
3397    } else {
3398        "raise [tui].max_tool_rounds in your config, or ask a more focused question"
3399    }
3400}
3401
3402fn cap_exit_progress_block(label: &str, progress: Option<&str>) -> String {
3403    match progress {
3404        Some(p) => format!("\n\n{label}:\n{p}"),
3405        None => String::new(),
3406    }
3407}
3408
3409fn cap_exit_fallback(
3410    max_tool_rounds: usize,
3411    accumulated: Option<crate::TokenUsage>,
3412    wasted_calls: usize,
3413    progress: Option<&str>,
3414) -> String {
3415    let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3416    let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3417    let salvaged = cap_exit_progress_block("Progress captured before the summary failed", progress);
3418    format!(
3419        "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}, \
3420         and the final summarization request also failed — {advice}){salvaged}"
3421    )
3422}
3423
3424fn cap_exit_action_handoff_fallback(
3425    max_tool_rounds: usize,
3426    accumulated: Option<crate::TokenUsage>,
3427    wasted_calls: usize,
3428    progress: Option<&str>,
3429) -> String {
3430    let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3431    let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3432    let salvaged = cap_exit_progress_block("Progress captured at the tool-call limit", progress);
3433    format!(
3434        "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}; \
3435         the final tools-disabled summary described future tool actions instead \
3436         of final state, so Newt preserved the verified progress instead of \
3437         accepting that handoff — {advice}){salvaged}"
3438    )
3439}
3440
3441fn cap_exit_summary_is_action_handoff(content: &str) -> bool {
3442    crate::NudgeClassifier::load_default()
3443        .classify(content)
3444        .class
3445        == crate::NudgeClass::PendingAction
3446        || looks_like_unverified_stale_file_blocker(content)
3447}
3448
3449/// The cap-exit context threaded into a final tools-disabled summary (Step
3450/// 27.5): the round limit, accumulated usage, the count of failed tool calls
3451/// (drives honest advice), the salvaged `<plan>`/`<state>` progress, and the
3452/// #867 observed-paths manifest (verified paths from tool results, collected
3453/// before the trim could delete them).
3454struct CapExit {
3455    max_tool_rounds: usize,
3456    accumulated: Option<crate::TokenUsage>,
3457    wasted_calls: usize,
3458    progress: Option<String>,
3459    observed: Vec<String>,
3460}
3461
3462/// Final tools-disabled completion for the Ollama (`/api/chat`) path.
3463///
3464/// `messages` is the already-trimmed list (caller uses `trim_for_summary`).
3465/// `cap.accumulated` carries usage from the preceding tool-call rounds so it
3466/// survives even when this summary request fails.
3467async fn final_summary_ollama(
3468    client: &reqwest::Client,
3469    chat_url: &str,
3470    model: &str,
3471    mut messages: Vec<serde_json::Value>,
3472    cap: CapExit,
3473) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3474    let CapExit {
3475        max_tool_rounds,
3476        accumulated,
3477        wasted_calls,
3478        progress,
3479        observed,
3480    } = cap;
3481    messages.push(serde_json::json!({
3482        "role": "user",
3483        "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3484    }));
3485    // No `tools` key => the model cannot emit tool calls.
3486    let body = serde_json::json!({
3487        "model": model,
3488        "messages": &messages,
3489        "stream": false,
3490    });
3491    let retry = tui_retry_policy();
3492    let result = with_backoff_notify(
3493        &retry,
3494        || async {
3495            let resp = client
3496                .post(chat_url)
3497                .json(&body)
3498                .send()
3499                .await
3500                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3501            if !resp.status().is_success() {
3502                let status = resp.status();
3503                let text = resp.text().await.unwrap_or_default();
3504                anyhow::bail!("Ollama {status}: {text}");
3505            }
3506            resp.json::<serde_json::Value>()
3507                .await
3508                .map_err(anyhow::Error::from)
3509        },
3510        |_, _| {}, // no color context here; tracing::warn covers it
3511    )
3512    .await;
3513    match result {
3514        Ok(json) => {
3515            // #385: strip inline <think>…</think> reasoning Nemotron-style models emit
3516            // in the content stream (the separate `thinking` field is handled elsewhere).
3517            // All-reasoning content collapses to empty → the thinking-only recovery below.
3518            let (content, _reasoning) = crate::reasoning::split_reasoning(
3519                json["message"]["content"].as_str().unwrap_or(""),
3520            );
3521            let total = merge_round_usage(accumulated, ollama_usage(&json));
3522            if content.is_empty() {
3523                Ok((
3524                    cap_exit_fallback(
3525                        max_tool_rounds,
3526                        accumulated,
3527                        wasted_calls,
3528                        progress.as_deref(),
3529                    ),
3530                    false,
3531                    accumulated,
3532                ))
3533            } else if cap_exit_summary_is_action_handoff(&content) {
3534                Ok((
3535                    cap_exit_action_handoff_fallback(
3536                        max_tool_rounds,
3537                        accumulated,
3538                        wasted_calls,
3539                        progress.as_deref(),
3540                    ),
3541                    false,
3542                    total,
3543                ))
3544            } else {
3545                Ok((content, false, total))
3546            }
3547        }
3548        // On any failure (including exhausted retries), still return the
3549        // accumulated usage so the caller can log the tokens consumed.
3550        Err(_) => Ok((
3551            cap_exit_fallback(
3552                max_tool_rounds,
3553                accumulated,
3554                wasted_calls,
3555                progress.as_deref(),
3556            ),
3557            false,
3558            accumulated,
3559        )),
3560    }
3561}
3562
3563/// Final tools-disabled completion for the OpenAI (`/v1/chat/completions`) path.
3564///
3565/// `messages` is the already-trimmed list (caller uses `trim_for_summary`).
3566/// `accumulated` carries usage from the preceding tool-call rounds.
3567async fn final_summary_openai(
3568    client: &reqwest::Client,
3569    chat_url: &str,
3570    model: &str,
3571    api_key: Option<&str>,
3572    mut messages: Vec<serde_json::Value>,
3573    cap: CapExit,
3574) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3575    let CapExit {
3576        max_tool_rounds,
3577        accumulated,
3578        wasted_calls,
3579        progress,
3580        observed,
3581    } = cap;
3582    messages.push(serde_json::json!({
3583        "role": "user",
3584        "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3585    }));
3586    // Omit `tools` / `tool_choice` => the model cannot emit tool calls.
3587    let body = serde_json::json!({
3588        "model": model,
3589        "messages": &messages,
3590        "stream": false,
3591    });
3592    let retry = tui_retry_policy();
3593    let result = with_backoff_notify(
3594        &retry,
3595        || async {
3596            let mut req = client.post(chat_url).json(&body);
3597            if let Some(key) = api_key {
3598                req = req.bearer_auth(key);
3599            }
3600            let resp = req
3601                .send()
3602                .await
3603                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3604            if !resp.status().is_success() {
3605                let status = resp.status();
3606                let text = resp.text().await.unwrap_or_default();
3607                anyhow::bail!("inference endpoint {status}: {text}");
3608            }
3609            resp.json::<serde_json::Value>()
3610                .await
3611                .map_err(anyhow::Error::from)
3612        },
3613        |_, _| {},
3614    )
3615    .await;
3616    match result {
3617        Ok(json) => {
3618            // #385: strip inline <think>…</think> reasoning from the content.
3619            let (content, _reasoning) = crate::reasoning::split_reasoning(
3620                json["choices"][0]["message"]["content"]
3621                    .as_str()
3622                    .unwrap_or(""),
3623            );
3624            let total = merge_round_usage(accumulated, openai_usage(&json["usage"]));
3625            if content.is_empty() {
3626                Ok((
3627                    cap_exit_fallback(
3628                        max_tool_rounds,
3629                        accumulated,
3630                        wasted_calls,
3631                        progress.as_deref(),
3632                    ),
3633                    false,
3634                    accumulated,
3635                ))
3636            } else if cap_exit_summary_is_action_handoff(&content) {
3637                Ok((
3638                    cap_exit_action_handoff_fallback(
3639                        max_tool_rounds,
3640                        accumulated,
3641                        wasted_calls,
3642                        progress.as_deref(),
3643                    ),
3644                    false,
3645                    total,
3646                ))
3647            } else {
3648                Ok((content, false, total))
3649            }
3650        }
3651        Err(_) => Ok((
3652            cap_exit_fallback(
3653                max_tool_rounds,
3654                accumulated,
3655                wasted_calls,
3656                progress.as_deref(),
3657            ),
3658            false,
3659            accumulated,
3660        )),
3661    }
3662}
3663
3664/// OpenAI-compatible variant of [`chat_complete`]: the same agentic tool-call
3665/// loop, but over `POST {endpoint}/v1/chat/completions` with bearer auth and
3666/// the OpenAI `tool_calls` / `tool_call_id` / `usage` shapes.
3667///
3668/// Non-streaming for now — the final answer is returned (and printed by the
3669/// caller) rather than streamed token-by-token. Token-by-token SSE streaming
3670/// is a follow-up; functionally the loop is complete, including tools.
3671pub async fn openai_chat_complete(
3672    ctx: ChatCtx<'_>,
3673    mcp: &mut dyn McpTools,
3674) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
3675    let ChatCtx {
3676        url,
3677        model,
3678        kind: _,
3679        api_key,
3680        messages: mem_messages,
3681        task,
3682        workspace,
3683        color,
3684        markdown: _,
3685        tool_offload,
3686        spill_store,
3687        compaction_store,
3688        scratchpad,
3689        scratchpad_store,
3690        code_search,
3691        experience_store,
3692        step_ledger,
3693        caveats,
3694        max_tool_rounds,
3695        workflow_grace_rounds,
3696        tool_output_lines,
3697        debug,
3698        trace,
3699        num_ctx,
3700        connect_timeout_secs,
3701        inference_timeout_secs,
3702        mid_loop_trim_threshold,
3703        mid_loop_trim_tokens,
3704        max_ok_input,
3705        build_check_cmd,
3706        safe_context,
3707        recover_cw_400,
3708        mut note_sink,
3709        mut note_nudge,
3710        recall_source,
3711        memory_source,
3712        summarizer,
3713        compress_state,
3714        mut tool_events,
3715        mut phantom_reaches,
3716        mut permission_gate,
3717        mut on_round_usage,
3718        estimate_ratio,
3719        estimation,
3720        summary_input_cap_floor_chars,
3721        exec_floor,
3722        write_ledger,
3723        cancel,
3724        git_tool,
3725        crew_runner,
3726    } = ctx;
3727    // Headless callers may pass no session state (mirrors the Ollama path).
3728    let mut local_compress_state = CompressState::new();
3729    let compress_state = match compress_state {
3730        Some(s) => s,
3731        None => &mut local_compress_state,
3732    };
3733    let client = reqwest::Client::builder()
3734        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
3735        .timeout(std::time::Duration::from_secs(inference_timeout_secs))
3736        .build()?;
3737    let chat_url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
3738    let retry = tui_retry_policy();
3739    // The save_note tool is advertised only when a sink exists (Step 19.3);
3740    // recall only when a source exists (Step 17.5); memory_fetch only when a
3741    // memory source exists (#319) — mirrors the Ollama path.
3742    let advertise_save_note = note_sink.is_some();
3743    let advertise_recall = recall_source.is_some();
3744    let advertise_memory_fetch = memory_source.is_some();
3745    // Step 26.4 (#583): state tools only when the feature is on AND a store exists.
3746    let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
3747    // Step 26.5.5 (#582): the code_search tool when a searcher is present.
3748    let advertise_code_search = code_search.is_some();
3749    // Step 26.6a (#585): the experiential tools when a store is present.
3750    let advertise_experiential = experience_store.is_some();
3751    // Step 26.6b (#586): the scheduled plan tools when a ledger is present.
3752    let advertise_scheduled = step_ledger.is_some();
3753    let advertise_git = git_tool.is_some();
3754    let advertise_team = crew_runner.is_some();
3755
3756    let mut messages: Vec<serde_json::Value> = mem_messages
3757        .iter()
3758        .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
3759        .collect();
3760
3761    // In-band memory nudge (Step 19.3) — mirrors the Ollama path.
3762    if note_sink.is_some() {
3763        if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
3764            append_nudge_line(&mut messages, &line);
3765        }
3766    }
3767
3768    let mut accumulated_usage: Option<crate::TokenUsage> = None;
3769    let mut hallucination_count: u32 = 0;
3770    // Step 27.3/#771: guard against exact-repeat tool loops this run.
3771    let mut repeat_calls = RepeatCallGuard::default();
3772    // Hard context-window 400s recovered (parse limit → trim → retry). See #223.
3773    let mut cw_retries: u32 = 0;
3774    // No-tools recovery (mirrors the Ollama path): a model that rejects the
3775    // `tools` field 400s even on "hello"; drop tools and retry, notice once.
3776    let mut tools_supported = true;
3777    let mut tools_unsupported_notified = false;
3778    // Pre-send token budget gate; tightened mid-turn by a recovered 400
3779    // (#223). Phase 20 §2.1 max(proven, believed) semantics — no `num_ctx`
3780    // ceiling on this wire (limits are server-side, e.g. vLLM
3781    // --max-model-len), so the ceiling leg is `None`.
3782    let mut send_budget: Option<usize> = initial_send_budget(max_ok_input, safe_context, None);
3783    // Step 20.3: on this wire there is no `num_ctx` ceiling, so the send
3784    // budget is authoritative only when a believed window (`safe_context`)
3785    // seeds it. Cloud OpenAI-compatible models have no `/api/show` to seed
3786    // one, so their budget rests on the proven-good HWM alone — the guard
3787    // fails open rather than refusing. A cw-400 flips this true mid-turn.
3788    let mut send_budget_authoritative = safe_context.is_some();
3789    // Tool schemas ride along in every request body; count them once (18.1).
3790    let tools = merged_tool_definitions(
3791        mcp,
3792        advertise_save_note,
3793        advertise_recall,
3794        advertise_memory_fetch,
3795        advertise_git,
3796        advertise_team,
3797        advertise_scratchpad,
3798        advertise_code_search,
3799        advertise_experiential,
3800        advertise_scheduled,
3801    );
3802    let tool_tokens = estimate_value_tokens(&tools, estimation);
3803    // Phase 20 §2.3: per-turn calibration ratio + real-token schema overhead
3804    // (mirrors the Ollama path).
3805    let cal = sanitize_estimate_ratio(estimate_ratio);
3806    let tool_tokens_real = calibrate_up(tool_tokens, cal);
3807    // Truthful context-size tracker (prompt-tokens-preferred, Step 18.1).
3808    let mut prompt_tracker = PromptTracker::new();
3809    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
3810
3811    // #867 Part A: observed-paths ledger (matches the Ollama path).
3812    let mut observed_paths = claim_check::ObservedPaths::default();
3813    let observed_resolver = claim_check::workspace_resolver(workspace);
3814
3815    // Narrate-then-stop rescue counter (mirror of the Ollama path).
3816    let mut narration_nudges: usize = 0;
3817    // Pending-plan final-answer gate counter (mirror of the Ollama path).
3818    let mut pending_plan_nudges: usize = 0;
3819    // Unverified stale-file blocker rescue counter (mirror of the Ollama path).
3820    let mut stale_file_nudges: usize = 0;
3821    let nudge_classifier = crate::NudgeClassifier::load_default();
3822    let workflow_steerer = crate::WorkflowSteerer::load_default();
3823    let mut workflow_runtime = WorkflowRuntimeState::default();
3824    // See the Ollama path: a matching workflow's grace-horizon override.
3825    workflow_runtime.set_progress_horizon(
3826        workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
3827    );
3828
3829    // Agentic loop — up to `max_tool_rounds` tool-call rounds (matches the
3830    // Ollama path), plus a configurable workflow grace window when the normal
3831    // cap would stop during active workflow progress.
3832    let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
3833    let mut workflow_grace_active = false;
3834    let mut current_tool_round_limit = max_tool_rounds;
3835    'round_loop: for round in 0..hard_tool_rounds {
3836        if round >= current_tool_round_limit {
3837            if workflow_grace_active {
3838                break;
3839            }
3840            let Some(nudge) = workflow_runtime.cap_grace_nudge(
3841                step_ledger,
3842                max_tool_rounds,
3843                workflow_grace_rounds,
3844            ) else {
3845                break;
3846            };
3847            workflow_grace_active = true;
3848            current_tool_round_limit = hard_tool_rounds;
3849            if debug {
3850                print_debug(
3851                    "workflow progress at soft round cap — granting configured grace window",
3852                    color,
3853                );
3854            }
3855            messages.push(serde_json::json!({ "role": "user", "content": nudge }));
3856        }
3857        // Interrupt checkpoint (Esc / Ctrl-C), same contract as the Ollama path:
3858        // bail at the round boundary with an empty reply; the caller treats the
3859        // turn as abandoned. (The OpenAI path's per-request awaits are not yet
3860        // individually raced — round granularity is enough for the opt-in
3861        // provider plugin; the Ollama first-class path cancels mid-await.)
3862        if is_cancelled(cancel) {
3863            return Ok((String::new(), false, accumulated_usage, hallucination_count));
3864        }
3865        if round > 0 && color {
3866            execute!(
3867                io::stdout(),
3868                SetForegroundColor(CtColor::DarkGrey),
3869                Print("…\n"),
3870                ResetColor
3871            )
3872            .ok();
3873        }
3874
3875        // Conditional plan re-seat (#630 b) — mirror of the Ollama path: re-show
3876        // the active step each round so a multi-step plan doesn't go stale.
3877        if round > 0 {
3878            if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
3879                messages.push(serde_json::json!({ "role": "user", "content": ptr }));
3880            }
3881            if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
3882                messages.push(serde_json::json!({ "role": "user", "content": nudge }));
3883            }
3884        }
3885
3886        // Context compression (Step 18.4, #247 — mirrors the Ollama path):
3887        // the shared prune → boundary → redacted summary → marker pipeline
3888        // serves the mid-loop trigger and the pre-send budget guard.
3889        {
3890            // Phase 20 §2.3: calibrated `current` (real-token space) —
3891            // mirrors the Ollama path.
3892            let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
3893            // Count-only budget priced in message-token space (F1) — mirrors
3894            // the Ollama path.
3895            let message_tokens = estimate_tokens(&messages, estimation);
3896            if let Some(trigger) = compression_trigger(
3897                messages.len(),
3898                current,
3899                message_tokens,
3900                mid_loop_trim_threshold,
3901                mid_loop_trim_tokens,
3902                send_budget,
3903                tool_tokens_real,
3904            ) {
3905                // Hard budgets are real-token currency → pipeline chars/4
3906                // (Phase 20 §2.3); count-only budgets pass through (F1).
3907                let pipeline_budget = if trigger.hard_budget {
3908                    calibrate_down(trigger.budget, cal)
3909                } else {
3910                    trigger.budget
3911                };
3912                // Step 20.3: authoritative iff a token threshold fired or the
3913                // send budget rests on a believed ceiling (mirrors the Ollama
3914                // loop). A lone-HWM guard is non-authoritative → fails open.
3915                let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
3916                let outcome = compress(
3917                    CompressRequest {
3918                        messages: &messages,
3919                        budget: pipeline_budget,
3920                        max_messages: trigger.max_messages,
3921                        task,
3922                        hard_budget: trigger.hard_budget,
3923                        authoritative: token_fired || send_budget_authoritative,
3924                        focus: None,
3925                        est: estimation,
3926                        summary_input_cap_floor_chars,
3927                        compaction_store,
3928                    },
3929                    summarizer,
3930                    compress_state,
3931                )
3932                .await;
3933                if let Some(notice) = outcome.notice {
3934                    print_harness_notice(&notice, color);
3935                }
3936                if outcome.action == CompressAction::Refused {
3937                    anyhow::bail!(
3938                        "context (~{current} tokens) exceeds the model's input budget and \
3939                         auto-compression is disabled after repeated ineffective passes — \
3940                         start a new conversation or ask a more focused question, or run \
3941                         `newt tunings reset {model}` if this model's learned budget looks wrong"
3942                    );
3943                }
3944                if outcome.fired {
3945                    // N2 (mirrors the Ollama path): flag a still-over-budget
3946                    // assembly in the notice — compared in the pipeline's
3947                    // own chars/4 currency (Phase 20 §2.3).
3948                    let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
3949                        ", still over budget"
3950                    } else {
3951                        ""
3952                    };
3953                    emit_compression_notice(
3954                        color,
3955                        outcome.tokens_before,
3956                        outcome.tokens_after,
3957                        outcome.action,
3958                        suffix,
3959                    );
3960                    if debug {
3961                        print_debug(
3962                            &format!(
3963                                "compression: {} → {} messages (budget ~{} tokens, \
3964                                 +~{tool_tokens} tool-schema tokens ride along)",
3965                                messages.len(),
3966                                outcome.messages.len(),
3967                                pipeline_budget,
3968                            ),
3969                            color,
3970                        );
3971                    }
3972                    messages = outcome.messages;
3973                    prompt_tracker.invalidate();
3974                }
3975            }
3976        }
3977
3978        // Phase 20 §2.2: chars/4 estimate of exactly the request about to be
3979        // dispatched — mirrors the Ollama path.
3980        let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
3981
3982        // OpenAI-compatible endpoints don't use Ollama's `options.num_ctx` —
3983        // context limits are configured server-side (vLLM --max-model-len).
3984        let mut body = serde_json::json!({
3985            "model": model,
3986            "messages": messages,
3987            "tools": tools.clone(),
3988            "tool_choice": "auto",
3989            "stream": false,
3990        });
3991        // Drop tools (and the now-meaningless tool_choice) for a model that
3992        // rejected them on a prior "does not support tools" 400.
3993        if !tools_supported {
3994            if let Some(o) = body.as_object_mut() {
3995                o.remove("tools");
3996                o.remove("tool_choice");
3997            }
3998        }
3999        // No `num_ctx` is sent here, so #282's per-request input ceiling has
4000        // no value to key on either — the pre-send guard stays on the cached
4001        // `max_ok_input` ∥ `safe_context` numbers and the cw-400 recovery
4002        // (these endpoints DO reject oversize requests with a parseable 400,
4003        // unlike Ollama's silent truncation).
4004        let _ = num_ctx; // not applicable for OpenAI-compatible endpoints
4005        let dispatch = with_backoff_notify(
4006            &retry,
4007            || async {
4008                let mut req = client.post(&chat_url).json(&body);
4009                if let Some(key) = api_key {
4010                    req = req.bearer_auth(key);
4011                }
4012                let resp = req
4013                    .send()
4014                    .await
4015                    .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
4016                if !resp.status().is_success() {
4017                    let status = resp.status();
4018                    let text = resp.text().await.unwrap_or_default();
4019                    anyhow::bail!("inference endpoint {status}: {text}");
4020                }
4021                resp.json::<serde_json::Value>()
4022                    .await
4023                    .map_err(anyhow::Error::from)
4024            },
4025            |attempt, delay| print_retry_indicator(attempt, delay, color),
4026        )
4027        .await;
4028        let json: serde_json::Value = match dispatch {
4029            Ok(j) => j,
4030            Err(e) => {
4031                // No-tools recovery: a model that rejects the `tools` field
4032                // (deepseek-r1) 400s even on "hello". Drop tools, notice once,
4033                // and re-dispatch the same round — self-limiting (the rebuilt
4034                // body omits tools) and session-persistent.
4035                if tools_supported && is_tools_unsupported_error(&e) {
4036                    tools_supported = false;
4037                    if !tools_unsupported_notified {
4038                        tools_unsupported_notified = true;
4039                        print_newt(
4040                            &format!(
4041                                "{model} does not support tools — tools disabled for this session"
4042                            ),
4043                            color,
4044                            false,
4045                        );
4046                    }
4047                    continue 'round_loop;
4048                }
4049                // Graceful context-window 400 recovery: parse the model's real
4050                // limit, tighten the budget, compress, and retry once (#223;
4051                // compress-not-trim since Step 18.4).
4052                if cw_retries < 2 {
4053                    if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
4054                        emit_overflow_notice(
4055                            color,
4056                            accumulated_usage.as_ref(),
4057                            Some(new_cap),
4058                            model,
4059                            cw_retries + 1,
4060                        );
4061                        send_budget = Some(new_cap as usize);
4062                        // The endpoint's parsed hard limit is authoritative
4063                        // from here on (Step 20.3; mirrors the Ollama path).
4064                        send_budget_authoritative = true;
4065                        let outcome = compress(
4066                            CompressRequest {
4067                                // Real-token cap minus real-token schema
4068                                // overhead → pipeline chars/4 currency
4069                                // (Phase 20 §2.3; mirrors the Ollama path).
4070                                messages: &messages,
4071                                budget: calibrate_down(
4072                                    (new_cap as usize).saturating_sub(tool_tokens_real),
4073                                    cal,
4074                                ),
4075                                max_messages: None,
4076                                task,
4077                                hard_budget: true,
4078                                authoritative: true,
4079                                focus: None,
4080                                est: estimation,
4081                                summary_input_cap_floor_chars,
4082                                compaction_store,
4083                            },
4084                            summarizer,
4085                            compress_state,
4086                        )
4087                        .await;
4088                        if let Some(notice) = outcome.notice {
4089                            print_harness_notice(&notice, color);
4090                        }
4091                        if outcome.action == CompressAction::Refused {
4092                            // Refuse the resend; surface the endpoint's 400.
4093                            return Err(e);
4094                        }
4095                        if outcome.fired {
4096                            messages = outcome.messages;
4097                            prompt_tracker.invalidate();
4098                        }
4099                        cw_retries += 1;
4100                        continue 'round_loop;
4101                    }
4102                }
4103                return Err(e);
4104            }
4105        };
4106        // Merge per-round token usage (input = max single prompt, output =
4107        // sum — Step 18.1) and anchor the context-size tracker.
4108        let round_usage = openai_usage(&json["usage"]);
4109        if let Some(u) = round_usage {
4110            prompt_tracker.record(u.input_tokens, messages.len());
4111        }
4112        accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
4113
4114        // Phase 20 §2.2: no `num_ctx` on this wire, so there is no silent
4115        // head-truncation mode to suspect (oversize requests get a parseable
4116        // 400 instead) and no per-request ceiling on the mid-turn raise.
4117        let truncation_suspect = false;
4118        if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
4119            let raised = u.input_tokens as usize;
4120            if raised > budget {
4121                send_budget = Some(raised);
4122                if debug {
4123                    print_debug(
4124                        &format!(
4125                            "send budget raised to ~{raised} tokens (backend accepted \
4126                             {}-token prompt)",
4127                            u.input_tokens
4128                        ),
4129                        color,
4130                    );
4131                }
4132            }
4133        }
4134
4135        let message = &json["choices"][0]["message"];
4136
4137        // #857: split the reasoning OFF the answer. A `<think>` block (reasoning
4138        // parser off) must never be returned or fed to the content-scrape recovery,
4139        // and the separate `reasoning_content` channel (reasoning parser on) is read
4140        // but never concatenated into the reply. Normal replies (no reasoning) are
4141        // unchanged: `split_reasoning` returns the content verbatim.
4142        let (oa_content, inline_reasoning) =
4143            crate::reasoning::split_reasoning(message["content"].as_str().unwrap_or(""));
4144        let separate_reasoning = message["reasoning_content"]
4145            .as_str()
4146            .map(str::trim)
4147            .filter(|s| !s.is_empty());
4148        if debug && (separate_reasoning.is_some() || inline_reasoning.is_some()) {
4149            let n = separate_reasoning
4150                .map(str::len)
4151                .or_else(|| inline_reasoning.as_deref().map(str::len))
4152                .unwrap_or(0);
4153            print_debug(
4154                &format!("reasoning ({n} chars) surfaced to the trace, not the answer"),
4155                color,
4156            );
4157        }
4158        let native_calls = message["tool_calls"].as_array();
4159        // Recover tool calls emitted as content instead of the native field —
4160        // the #1 weak-model failure (see `tool_recovery`). Mirror of the Ollama
4161        // loop: a local vLLM/llama.cpp server reports OpenAI-wire, so weak models
4162        // there drop content-emitted calls too. Recovered calls are native-shaped
4163        // and flow into the executor + is_hallucination path below.
4164        let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
4165            tool_recovery::recover_tool_calls(&oa_content)
4166        } else {
4167            tool_recovery::Recovery::default()
4168        };
4169        let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
4170            Some(t) if !t.is_empty() => Some(t),
4171            _ if !recovered.calls.is_empty() => Some(&recovered.calls),
4172            _ => None,
4173        };
4174        let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
4175        if debug && !recovered.calls.is_empty() {
4176            print_debug(
4177                &format!(
4178                    "recovered {} tool call(s) from content (non-native emission)",
4179                    recovered.calls.len()
4180                ),
4181                color,
4182            );
4183        }
4184
4185        if debug {
4186            let excerpt: String = oa_content.chars().take(80).collect();
4187            let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
4188            let usage_str = match round_usage {
4189                Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
4190                None => "no usage".into(),
4191            };
4192            print_debug(
4193                &format!(
4194                    "round {round}: tool_calls={tc_count} usage=[{usage_str}] content={excerpt:?}"
4195                ),
4196                color,
4197            );
4198        }
4199
4200        if !has_tools {
4201            // Format-hallucination tracker (mirror of the Ollama loop): content
4202            // that looked like a tool call but couldn't be recovered is counted.
4203            if recovered.tool_shaped {
4204                hallucination_count += 1;
4205                if debug {
4206                    print_debug(
4207                        "format-hallucination: tool call emitted as unrecoverable text",
4208                        color,
4209                    );
4210                }
4211            }
4212            let content = oa_content.clone();
4213            if content.is_empty() && debug {
4214                print_debug(
4215                    "empty content with no tool calls — model produced nothing",
4216                    color,
4217                );
4218            }
4219            // Narrate-then-stop rescue (mirror of the Ollama loop): non-empty
4220            // prose with no tool call. Nudge once and continue instead of ending
4221            // the turn — bounded by NARRATION_NUDGE_CAP + the round budget.
4222            let nudge_classification =
4223                (!content.is_empty()).then(|| nudge_classifier.classify(&content));
4224            let workflow_classifier_text = workflow_classifier_text(&messages, &content);
4225            let workflow_hint = nudge_classification
4226                .as_ref()
4227                .filter(|classification| classification.is_plan_update())
4228                .and_then(|_| workflow_steerer.plan_update_hint(&workflow_classifier_text));
4229            let classifier_plan_direction = nudge_classification
4230                .as_ref()
4231                .filter(|classification| classification.is_plan_update())
4232                .and_then(|_| nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate));
4233            let plan_nudge_hint =
4234                combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
4235            if !content.is_empty() && round + 1 < current_tool_round_limit {
4236                if let Some(nudge) = workflow_runtime.rediscovery_nudge(
4237                    nudge_classification.as_ref(),
4238                    &content,
4239                    step_ledger,
4240                ) {
4241                    if debug {
4242                        print_debug(
4243                            "workflow evidence rediscovery — nudging toward active repair",
4244                            color,
4245                        );
4246                    }
4247                    messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4248                    messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4249                    continue 'round_loop;
4250                }
4251            }
4252            if !content.is_empty()
4253                && pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
4254                && round + 1 < current_tool_round_limit
4255            {
4256                let needs_plan_update = nudge_classification
4257                    .as_ref()
4258                    .is_some_and(|c| c.is_plan_update());
4259                if let Some(nudge) = pending_plan_completion_nudge(
4260                    step_ledger,
4261                    needs_plan_update,
4262                    plan_nudge_hint.as_deref(),
4263                ) {
4264                    if debug {
4265                        print_debug(
4266                            "active plan has unfinished steps — nudging before final answer",
4267                            color,
4268                        );
4269                    }
4270                    messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4271                    messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4272                    pending_plan_nudges += 1;
4273                    continue 'round_loop;
4274                }
4275            }
4276            if !content.is_empty()
4277                && stale_file_nudges < STALE_FILE_NUDGE_CAP
4278                && round + 1 < current_tool_round_limit
4279                && looks_like_unverified_stale_file_blocker(&content)
4280            {
4281                if debug {
4282                    print_debug(
4283                        "unverified stale-file blocker — nudging to check ground truth",
4284                        color,
4285                    );
4286                }
4287                messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4288                messages.push(serde_json::json!({
4289                    "role": "user",
4290                    "content": stale_file_ground_truth_nudge(),
4291                }));
4292                stale_file_nudges += 1;
4293                continue 'round_loop;
4294            }
4295            if !content.is_empty()
4296                && narration_nudges < NARRATION_NUDGE_CAP
4297                && round + 1 < current_tool_round_limit
4298                && nudge_classification
4299                    .as_ref()
4300                    .is_some_and(|c| c.is_pending_action())
4301            {
4302                if debug {
4303                    print_debug(
4304                        "narrated intent with no tool call — nudging to act and continuing",
4305                        color,
4306                    );
4307                }
4308                messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4309                let direction = nudge_classification
4310                    .as_ref()
4311                    .and_then(|classification| nudge_classifier.direction_for(classification.class))
4312                    .map(str::to_string)
4313                    .unwrap_or_else(narration_action_nudge);
4314                messages.push(serde_json::json!({
4315                    "role": "user",
4316                    "content": direction,
4317                }));
4318                narration_nudges += 1;
4319                continue 'round_loop;
4320            }
4321            // Phase 20 §2.2: non-empty final content is usable output —
4322            // report the accepted prompt before returning.
4323            if !content.is_empty() {
4324                emit_accepted(
4325                    &mut on_round_usage,
4326                    round_usage,
4327                    truncation_suspect,
4328                    round_est_raw,
4329                );
4330            }
4331            let out = if content.is_empty() {
4332                "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
4333            } else {
4334                content
4335            };
4336            return Ok((out, false, accumulated_usage, hallucination_count));
4337        }
4338
4339        // Record the assistant turn (it carries the tool_calls), then run each call
4340        // and feed the result back keyed by its tool_call_id.
4341        // Phase 20 §2.2: tool calls are usable output (mirrors the Ollama path).
4342        emit_accepted(
4343            &mut on_round_usage,
4344            round_usage,
4345            truncation_suspect,
4346            round_est_raw,
4347        );
4348        // #857: re-send a CLEAN assistant turn — stripped content (no inline
4349        // <think>) and no prior-turn `reasoning_content` (the model must not be fed
4350        // its own CoT back). The `tool_calls` are preserved.
4351        let mut assistant_turn = message.clone();
4352        assistant_turn["content"] = serde_json::Value::String(oa_content.clone());
4353        if let Some(obj) = assistant_turn.as_object_mut() {
4354            obj.remove("reasoning_content");
4355        }
4356        messages.push(assistant_turn);
4357        let mut round_modified_workspace = false;
4358        let mut round_progress = false;
4359        for tc in tool_calls.unwrap() {
4360            let id = tc["id"].as_str().unwrap_or("");
4361            // Some API proxies (e.g. NVIDIA inference → Anthropic backend) wrap
4362            // Anthropic-native tool-use blocks inside the OpenAI `tool_calls`
4363            // array without converting the inner schema.  Fall back from the
4364            // OpenAI path (`function.name` / `function.arguments`) to the
4365            // Anthropic-native path (`name` / `input`) when the `function` key
4366            // is absent, so both wire formats route correctly.
4367            let anthropic_native = tc["function"].is_null();
4368            if anthropic_native && debug {
4369                let raw_name = tc["name"].as_str().unwrap_or("<missing>");
4370                print_debug(
4371                    &format!(
4372                        "tool call in Anthropic-native format inside tool_calls array \
4373                         (no `function` key) — name={raw_name:?}"
4374                    ),
4375                    color,
4376                );
4377            }
4378            let name = if anthropic_native {
4379                tc["name"].as_str().unwrap_or("unknown")
4380            } else {
4381                tc["function"]["name"].as_str().unwrap_or("unknown")
4382            };
4383            let args = if anthropic_native {
4384                tc["input"].clone()
4385            } else {
4386                match &tc["function"]["arguments"] {
4387                    serde_json::Value::String(s) => {
4388                        serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
4389                    }
4390                    v => v.clone(),
4391                }
4392            };
4393            if trace {
4394                print_trace(
4395                    &format!(
4396                        "raw tool_call element: {}",
4397                        serde_json::to_string(tc).unwrap_or_else(|_| "?".into())
4398                    ),
4399                    color,
4400                );
4401            }
4402            let mcp_handles = mcp.handles(name);
4403            if debug {
4404                print_debug(
4405                    &format!("dispatching tool name={name:?} mcp_handles={mcp_handles}"),
4406                    color,
4407                );
4408            }
4409            if is_hallucination(name, &args) {
4410                hallucination_count += 1;
4411            }
4412            // Step 27.3/#771: short-circuit selected exact repeats (mirrors the
4413            // Ollama path; Responses uses function_call_output). Counted as a
4414            // hallucination above first when applicable.
4415            if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
4416                if let Some(rec) = tool_events.as_deref_mut() {
4417                    rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
4418                }
4419                messages.push(serde_json::json!({
4420                    "role": "tool",
4421                    "tool_call_id": id,
4422                    "content": steer,
4423                }));
4424                continue;
4425            }
4426            // Organic save_note use resets the memory-nudge counter (mirrors
4427            // the Ollama path).
4428            if name == "save_note" && note_sink.is_some() {
4429                if let Some(n) = note_nudge.as_deref_mut() {
4430                    n.note_saved();
4431                }
4432            }
4433            // retry technique: snapshot the file's pre-write bytes before the
4434            // write tool runs, so the post-turn gate can revert exactly newt's writes.
4435            ledger_note_write(write_ledger, name, &args, workspace);
4436            let tool_t0 = std::time::Instant::now();
4437            // #727: intercept the read-only budget self-read (see the Ollama path).
4438            // num_ctx is not applicable on OpenAI-compatible endpoints (so the
4439            // ceiling is usually None → an honest "no ceiling configured"), but the
4440            // used-token figure is still reported.
4441            let result = if tools::is_context_remaining_call(name) {
4442                let report = budget::render_context_budget(
4443                    prompt_tracker.current(&messages, Some(&tools), cal, estimation),
4444                    num_ctx_input_ceiling(num_ctx),
4445                    num_ctx,
4446                );
4447                display::print_tool_call("get_context_remaining", "", color);
4448                display::print_tool_output(&report, tool_output_lines, color);
4449                report
4450            } else {
4451                execute_tool_with_offload(
4452                    name,
4453                    &args,
4454                    workspace,
4455                    color,
4456                    tool_output_lines,
4457                    caveats,
4458                    mcp,
4459                    build_check_cmd.as_deref(),
4460                    // Reborrow + re-coerce: shortens the trait-object lifetime to
4461                    // this call (Option<&mut dyn _> is invariant, so the longer
4462                    // ChatCtx lifetime can't unify directly).
4463                    note_sink
4464                        .as_deref_mut()
4465                        .map(|s| &mut *s as &mut dyn NoteSink),
4466                    recall_source,
4467                    memory_source,
4468                    // #263 prompted grants — same reborrow pattern as note_sink.
4469                    permission_gate
4470                        .as_deref_mut()
4471                        .map(|g| &mut *g as &mut dyn PermissionGate),
4472                    // #307: the active preset's exec floor (the bypass ceiling).
4473                    exec_floor,
4474                    // PR4: the injected embedded-git capability (None for headless).
4475                    git_tool,
4476                    // #479: the injected crew/team orchestration (None for headless).
4477                    crew_runner,
4478                    scratchpad_store,
4479                    code_search,
4480                    experience_store,
4481                    step_ledger,
4482                    tool_offload,
4483                    spill_store,
4484                )
4485                .await
4486            };
4487            if debug {
4488                let excerpt: String = result.chars().take(120).collect();
4489                print_debug(&format!("tool result: {excerpt:?}"), color);
4490            }
4491            // 17.6: record the call for the turn's events column (mirrors
4492            // the Ollama path) — digested args, duration as a display claim.
4493            // Step 27.3/#771: classify once; remember repeat-steered outcomes
4494            // (mirrors Ollama path).
4495            let ok = tools::tool_result_ok(&result);
4496            if ok && is_workspace_write_call(name) {
4497                round_modified_workspace = true;
4498            }
4499            if ok && meaningful_workflow_progress(name, &result) {
4500                round_progress = true;
4501            }
4502            repeat_calls.record(name, &args, ok, &result);
4503            if workflow_runtime.record_tool_result(&result) {
4504                round_progress = true;
4505            }
4506            if let Some(rec) = tool_events.as_deref_mut() {
4507                rec.push(crate::ToolEvent::from_call(
4508                    name,
4509                    &args,
4510                    ok,
4511                    u64::try_from(tool_t0.elapsed().as_millis()).ok(),
4512                ));
4513            }
4514            // #717: record any phantom/capability reach (alias / hallucination
4515            // / real-tool empty miss) for the alias-seam telemetry. #479 (G4)
4516            // composes the gated-off seam here, where `advertise_team` is known:
4517            // a `crew`/`compose_roster` reach with the surface OFF is a real name
4518            // (so `classify_phantom_reach` never flags it) but exactly the
4519            // delegation signal we want to mine for the common OFF default.
4520            if let Some(pr) = phantom_reaches.as_deref_mut() {
4521                if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
4522                    .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
4523                {
4524                    pr.push(crate::PhantomReach {
4525                        name_as_called: name.to_string(),
4526                        resolution,
4527                        active_context_features: Vec::new(),
4528                    });
4529                }
4530            }
4531            // #867 Part A: ledger verified paths (see the Ollama path).
4532            observed_paths.record(&result, &observed_resolver);
4533            messages.push(serde_json::json!({
4534                "role": "tool",
4535                "tool_call_id": id,
4536                // Step 26.3 (#584): see the Ollama path.
4537                "content": maybe_offload_tool_result(name, result, tool_offload, spill_store),
4538            }));
4539        }
4540        workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
4541    }
4542
4543    // Reached the round cap. Trim the message list and make ONE final
4544    // tools-disabled completion (matches the Ollama path).
4545    let trimmed = trim_for_summary(&messages, 2, 6);
4546    // Step 27.5: salvage progress + failed-call count (matches the Ollama path).
4547    let progress = cap_exit_progress(step_ledger, scratchpad_store);
4548    let (text, streamed, usage) = final_summary_openai(
4549        &client,
4550        &chat_url,
4551        model,
4552        api_key,
4553        trimmed,
4554        CapExit {
4555            max_tool_rounds,
4556            accumulated: accumulated_usage,
4557            wasted_calls: repeat_calls.total_failures(),
4558            progress,
4559            observed: observed_paths.into_vec(),
4560        },
4561    )
4562    .await?;
4563    // #867: same path-claim refutation as the Ollama cap exit.
4564    let text = claim_check::annotate_against_workspace(text, workspace);
4565    Ok((text, streamed, usage, hallucination_count))
4566}
4567
4568// ── OpenAI Responses API (`POST /v1/responses`) ────────────────────────────
4569//
4570// The newer OpenAI surface. Models like `gpt-5-codex` are served ONLY here and
4571// 404 on `/v1/chat/completions`. The request/response shapes differ (input vs
4572// messages, instructions vs system message, a flatter tool schema, `output[]`
4573// items vs `choices`, `input_tokens`/`output_tokens` usage), so this is a
4574// parallel — deliberately leaner — loop. Selected per backend via
4575// `api = "responses"` (surfaced to the loop as `NEWT_OPENAI_API`). Non-streaming
4576// in v1 (matching the chat path's UX); the chat path's budget / cw-400 recovery
4577// is intentionally not duplicated here yet (opt-in path) — tracked.
4578
4579/// `true` when the active OpenAI backend selected the Responses API
4580/// (`[backends].api = "responses"`, surfaced to the loop as `NEWT_OPENAI_API`).
4581fn responses_api_selected() -> bool {
4582    std::env::var("NEWT_OPENAI_API")
4583        .ok()
4584        .is_some_and(|v| v.eq_ignore_ascii_case("responses"))
4585}
4586
4587/// Split chat-style messages into the Responses API's `(instructions, input)`:
4588/// `system`/`developer` messages concatenate into top-level `instructions`;
4589/// `user`/`assistant` become `input` message items (plain string content). Any
4590/// item already shaped as a Responses item (carrying a `type` field, e.g.
4591/// `function_call` / `function_call_output`) passes through untouched.
4592fn build_responses_input(
4593    messages: &[serde_json::Value],
4594) -> (Option<String>, Vec<serde_json::Value>) {
4595    let mut instructions: Vec<String> = Vec::new();
4596    let mut input: Vec<serde_json::Value> = Vec::new();
4597    for m in messages {
4598        if m.get("type").is_some() {
4599            input.push(m.clone());
4600            continue;
4601        }
4602        let role = m["role"].as_str().unwrap_or("user");
4603        let content = m["content"].as_str().unwrap_or("");
4604        match role {
4605            "system" | "developer" => instructions.push(content.to_string()),
4606            _ => input.push(serde_json::json!({ "role": role, "content": content })),
4607        }
4608    }
4609    let ins = (!instructions.is_empty()).then(|| instructions.join("\n\n"));
4610    (ins, input)
4611}
4612
4613/// Translate the chat/completions tool array (`{type:function,
4614/// function:{name,…}}` elements, as returned by `merged_tool_definitions`) to
4615/// the Responses API's flatter `{type:function, name, description, parameters}`.
4616/// An already-flat (or unknown) element passes through.
4617fn tools_to_responses(tools: &serde_json::Value) -> Vec<serde_json::Value> {
4618    tools
4619        .as_array()
4620        .map(|arr| {
4621            arr.iter()
4622                .map(|t| {
4623                    let f = &t["function"];
4624                    if f.is_object() {
4625                        serde_json::json!({
4626                            "type": "function",
4627                            "name": f["name"],
4628                            "description": f["description"],
4629                            "parameters": f["parameters"],
4630                        })
4631                    } else {
4632                        t.clone()
4633                    }
4634                })
4635                .collect()
4636        })
4637        .unwrap_or_default()
4638}
4639
4640/// Extract `(assistant_text, function_call_items)` from a Responses reply's
4641/// `output[]`: text is the concatenation of `output_text` parts inside
4642/// `message` items; `function_call` items are returned verbatim (they carry
4643/// `call_id` / `name` / `arguments` and are echoed back into the next request).
4644/// Falls back to a flattened top-level `output_text` if the structured walk
4645/// found no text.
4646fn parse_responses_output(json: &serde_json::Value) -> (String, Vec<serde_json::Value>) {
4647    let mut text = String::new();
4648    let mut calls = Vec::new();
4649    if let Some(items) = json["output"].as_array() {
4650        for item in items {
4651            match item["type"].as_str() {
4652                Some("message") => {
4653                    if let Some(parts) = item["content"].as_array() {
4654                        for p in parts {
4655                            if let Some(t) = p["text"].as_str() {
4656                                text.push_str(t);
4657                            }
4658                        }
4659                    }
4660                }
4661                Some("function_call") => calls.push(item.clone()),
4662                _ => {}
4663            }
4664        }
4665    }
4666    if text.is_empty() {
4667        if let Some(t) = json["output_text"].as_str() {
4668            text.push_str(t);
4669        }
4670    }
4671    (text, calls)
4672}
4673
4674/// Responses API usage → `TokenUsage` (`input_tokens`/`output_tokens`, distinct
4675/// from chat/completions' `prompt_tokens`/`completion_tokens`).
4676fn responses_usage(v: &serde_json::Value) -> Option<crate::TokenUsage> {
4677    let input = v["input_tokens"].as_u64().map(|n| n as u32);
4678    let output = v["output_tokens"].as_u64().map(|n| n as u32);
4679    input.zip(output).map(|(i, o)| crate::TokenUsage {
4680        input_tokens: i,
4681        output_tokens: o,
4682    })
4683}
4684
4685/// The OpenAI **Responses API** agentic loop (`POST {endpoint}/v1/responses`).
4686/// Parallel to [`openai_chat_complete`] but over the Responses shapes, for
4687/// models served only there (`gpt-5-codex`). Non-streaming; selected via
4688/// `api = "responses"`. The chat path's budget / cw-400 recovery is not yet
4689/// mirrored here (opt-in path) — tracked as a follow-up.
4690pub async fn openai_responses_complete(
4691    ctx: ChatCtx<'_>,
4692    mcp: &mut dyn McpTools,
4693) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
4694    let ChatCtx {
4695        url,
4696        model,
4697        kind: _,
4698        api_key,
4699        messages: mem_messages,
4700        task: _,
4701        workspace,
4702        color,
4703        markdown: _,
4704        tool_offload,
4705        spill_store,
4706        compaction_store,
4707        scratchpad,
4708        scratchpad_store,
4709        code_search,
4710        experience_store,
4711        step_ledger,
4712        caveats,
4713        max_tool_rounds,
4714        workflow_grace_rounds: _,
4715        tool_output_lines,
4716        debug,
4717        trace,
4718        // #727: bound (not `_`) so get_context_remaining can report the budget;
4719        // on the Responses wire num_ctx is normally unset (cloud), so the report
4720        // is honestly ceiling-less.
4721        num_ctx,
4722        connect_timeout_secs,
4723        inference_timeout_secs,
4724        mid_loop_trim_threshold: _,
4725        mid_loop_trim_tokens: _,
4726        max_ok_input: _,
4727        build_check_cmd,
4728        safe_context: _,
4729        recover_cw_400: _,
4730        mut note_sink,
4731        mut note_nudge,
4732        recall_source,
4733        memory_source,
4734        summarizer: _,
4735        compress_state: _,
4736        mut tool_events,
4737        mut phantom_reaches,
4738        mut permission_gate,
4739        on_round_usage: _,
4740        estimate_ratio: _,
4741        // #727: bound for the get_context_remaining used-token estimate.
4742        estimation,
4743        summary_input_cap_floor_chars: _,
4744        exec_floor,
4745        write_ledger,
4746        cancel,
4747        git_tool,
4748        crew_runner,
4749    } = ctx;
4750    // The OpenAI-Responses loop offloads tool output (spill_store) but does not
4751    // run the compressor, so it never stores compaction spans.
4752    let _ = compaction_store;
4753
4754    let client = reqwest::Client::builder()
4755        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
4756        .timeout(std::time::Duration::from_secs(inference_timeout_secs))
4757        .build()?;
4758    let responses_url = format!("{}/v1/responses", url.trim_end_matches('/'));
4759    let retry = tui_retry_policy();
4760    let advertise_save_note = note_sink.is_some();
4761    let advertise_recall = recall_source.is_some();
4762    let advertise_memory_fetch = memory_source.is_some();
4763    // Step 26.4 (#583): state tools only when the feature is on AND a store exists.
4764    let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
4765    // Step 26.5.5 (#582): the code_search tool when a searcher is present.
4766    let advertise_code_search = code_search.is_some();
4767    // Step 26.6a (#585): the experiential tools when a store is present.
4768    let advertise_experiential = experience_store.is_some();
4769    // Step 26.6b (#586): the scheduled plan tools when a ledger is present.
4770    let advertise_scheduled = step_ledger.is_some();
4771    let advertise_git = git_tool.is_some();
4772    let advertise_team = crew_runner.is_some();
4773
4774    let msgs_json: Vec<serde_json::Value> = mem_messages
4775        .iter()
4776        .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
4777        .collect();
4778    let (instructions, mut input) = build_responses_input(&msgs_json);
4779    let tools_chat = merged_tool_definitions(
4780        mcp,
4781        advertise_save_note,
4782        advertise_recall,
4783        advertise_memory_fetch,
4784        advertise_git,
4785        advertise_team,
4786        advertise_scratchpad,
4787        advertise_code_search,
4788        advertise_experiential,
4789        advertise_scheduled,
4790    );
4791    let tools = tools_to_responses(&tools_chat);
4792
4793    let mut accumulated_usage: Option<crate::TokenUsage> = None;
4794    let mut hallucination_count: u32 = 0;
4795    // Step 27.3/#771: guard against exact-repeat tool loops this run.
4796    let mut repeat_calls = RepeatCallGuard::default();
4797    let mut tools_supported = true;
4798    let mut tools_unsupported_notified = false;
4799
4800    let build_body = |input: &[serde_json::Value], with_tools: bool| {
4801        let mut body = serde_json::json!({ "model": model, "input": input, "stream": false });
4802        if let Some(ins) = &instructions {
4803            body["instructions"] = serde_json::json!(ins);
4804        }
4805        if with_tools && !tools.is_empty() {
4806            body["tools"] = serde_json::json!(tools);
4807            body["tool_choice"] = serde_json::json!("auto");
4808        }
4809        body
4810    };
4811
4812    for round in 0..max_tool_rounds {
4813        if is_cancelled(cancel) {
4814            return Ok((String::new(), false, accumulated_usage, hallucination_count));
4815        }
4816        let body = build_body(&input, tools_supported);
4817        let dispatch = with_backoff_notify(
4818            &retry,
4819            || async {
4820                let mut req = client.post(&responses_url).json(&body);
4821                if let Some(key) = api_key {
4822                    req = req.bearer_auth(key);
4823                }
4824                let resp = req
4825                    .send()
4826                    .await
4827                    .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
4828                if !resp.status().is_success() {
4829                    let status = resp.status();
4830                    let text = resp.text().await.unwrap_or_default();
4831                    anyhow::bail!("inference endpoint {status}: {text}");
4832                }
4833                resp.json::<serde_json::Value>()
4834                    .await
4835                    .map_err(anyhow::Error::from)
4836            },
4837            |attempt, delay| print_retry_indicator(attempt, delay, color),
4838        )
4839        .await;
4840
4841        let json = match dispatch {
4842            Ok(j) => j,
4843            Err(e) => {
4844                if tools_supported && is_tools_unsupported_error(&e) {
4845                    tools_supported = false;
4846                    if !tools_unsupported_notified {
4847                        tools_unsupported_notified = true;
4848                        print_newt(
4849                            &format!(
4850                                "{model} does not support tools — tools disabled for this session"
4851                            ),
4852                            color,
4853                            false,
4854                        );
4855                    }
4856                    continue;
4857                }
4858                return Err(e);
4859            }
4860        };
4861
4862        let round_usage = responses_usage(&json["usage"]);
4863        accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
4864        let (text, calls) = parse_responses_output(&json);
4865
4866        if debug {
4867            let excerpt: String = text.chars().take(80).collect();
4868            print_debug(
4869                &format!(
4870                    "responses round {round}: function_calls={} content={excerpt:?}",
4871                    calls.len()
4872                ),
4873                color,
4874            );
4875        }
4876
4877        if calls.is_empty() {
4878            let out = if text.is_empty() {
4879                "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
4880            } else {
4881                text
4882            };
4883            return Ok((out, false, accumulated_usage, hallucination_count));
4884        }
4885
4886        // Echo the model's function_call items back into the running input,
4887        // then run each and append its function_call_output.
4888        for call in &calls {
4889            input.push(call.clone());
4890        }
4891        for call in &calls {
4892            let call_id = call["call_id"]
4893                .as_str()
4894                .or_else(|| call["id"].as_str())
4895                .unwrap_or("");
4896            let name = call["name"].as_str().unwrap_or("unknown");
4897            let args = match &call["arguments"] {
4898                serde_json::Value::String(s) => {
4899                    serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
4900                }
4901                v => v.clone(),
4902            };
4903            if trace {
4904                print_trace(
4905                    &format!(
4906                        "raw function_call: {}",
4907                        serde_json::to_string(call).unwrap_or_else(|_| "?".into())
4908                    ),
4909                    color,
4910                );
4911            }
4912            if is_hallucination(name, &args) {
4913                hallucination_count += 1;
4914            }
4915            // Step 27.3/#771: short-circuit selected exact repeats (Responses
4916            // shape: echo a function_call_output with the steer).
4917            // Counted as a hallucination above first when applicable.
4918            if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
4919                if let Some(rec) = tool_events.as_deref_mut() {
4920                    rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
4921                }
4922                input.push(serde_json::json!({
4923                    "type": "function_call_output",
4924                    "call_id": call_id,
4925                    "output": steer,
4926                }));
4927                continue;
4928            }
4929            if name == "save_note" && note_sink.is_some() {
4930                if let Some(n) = note_nudge.as_deref_mut() {
4931                    n.note_saved();
4932                }
4933            }
4934            ledger_note_write(write_ledger, name, &args, workspace);
4935            let tool_t0 = std::time::Instant::now();
4936            // #727: intercept the read-only budget self-read (see the Ollama path).
4937            // The Responses loop has no PromptTracker, so `used` is the chars/4
4938            // estimate of the running `input` plus tool schemas; num_ctx is normally
4939            // unset here, so the report is honestly ceiling-less.
4940            let result = if tools::is_context_remaining_call(name) {
4941                let report = budget::render_context_budget(
4942                    estimate_request_tokens(&input, Some(&tools_chat), estimation),
4943                    num_ctx_input_ceiling(num_ctx),
4944                    num_ctx,
4945                );
4946                display::print_tool_call("get_context_remaining", "", color);
4947                display::print_tool_output(&report, tool_output_lines, color);
4948                report
4949            } else {
4950                execute_tool_with_offload(
4951                    name,
4952                    &args,
4953                    workspace,
4954                    color,
4955                    tool_output_lines,
4956                    caveats,
4957                    mcp,
4958                    build_check_cmd.as_deref(),
4959                    note_sink
4960                        .as_deref_mut()
4961                        .map(|s| &mut *s as &mut dyn NoteSink),
4962                    recall_source,
4963                    memory_source,
4964                    permission_gate
4965                        .as_deref_mut()
4966                        .map(|g| &mut *g as &mut dyn PermissionGate),
4967                    exec_floor,
4968                    git_tool,
4969                    crew_runner,
4970                    scratchpad_store,
4971                    code_search,
4972                    experience_store,
4973                    step_ledger,
4974                    tool_offload,
4975                    spill_store,
4976                )
4977                .await
4978            };
4979            if debug {
4980                let excerpt: String = result.chars().take(120).collect();
4981                print_debug(&format!("tool result: {excerpt:?}"), color);
4982            }
4983            // Step 27.3/#771: classify once; remember repeat-steered outcomes
4984            // (mirrors Ollama path).
4985            let ok = tools::tool_result_ok(&result);
4986            repeat_calls.record(name, &args, ok, &result);
4987            if let Some(rec) = tool_events.as_deref_mut() {
4988                rec.push(crate::ToolEvent::from_call(
4989                    name,
4990                    &args,
4991                    ok,
4992                    u64::try_from(tool_t0.elapsed().as_millis()).ok(),
4993                ));
4994            }
4995            // #717: record any phantom/capability reach (alias / hallucination
4996            // / real-tool empty miss) for the alias-seam telemetry. #479 (G4)
4997            // composes the gated-off seam here, where `advertise_team` is known:
4998            // a `crew`/`compose_roster` reach with the surface OFF is a real name
4999            // (so `classify_phantom_reach` never flags it) but exactly the
5000            // delegation signal we want to mine for the common OFF default.
5001            if let Some(pr) = phantom_reaches.as_deref_mut() {
5002                if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
5003                    .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
5004                {
5005                    pr.push(crate::PhantomReach {
5006                        name_as_called: name.to_string(),
5007                        resolution,
5008                        active_context_features: Vec::new(),
5009                    });
5010                }
5011            }
5012            input.push(serde_json::json!({
5013                "type": "function_call_output",
5014                "call_id": call_id,
5015                // Step 26.3 (#584): see the Ollama path (Responses output shape).
5016                "output": maybe_offload_tool_result(name, result, tool_offload, spill_store),
5017            }));
5018        }
5019    }
5020
5021    // Round cap: one final tools-disabled call for a summary answer (mirrors
5022    // the chat path's final_summary, in the Responses shape).
5023    let body = build_body(&input, false);
5024    let mut req = client.post(&responses_url).json(&body);
5025    if let Some(key) = api_key {
5026        req = req.bearer_auth(key);
5027    }
5028    let resp = req.send().await?;
5029    if !resp.status().is_success() {
5030        let status = resp.status();
5031        let text = resp.text().await.unwrap_or_default();
5032        anyhow::bail!("inference endpoint {status}: {text}");
5033    }
5034    let json: serde_json::Value = resp.json().await?;
5035    accumulated_usage = merge_round_usage(accumulated_usage, responses_usage(&json["usage"]));
5036    let (text, _) = parse_responses_output(&json);
5037    Ok((text, false, accumulated_usage, hallucination_count))
5038}
5039
5040/// Whether the reasoning spinner is enabled: `NEWT_THINKING` (set by
5041/// `/thinking`) overrides `[tui] thinking`; default on.
5042fn thinking_stream_enabled() -> bool {
5043    match std::env::var("NEWT_THINKING").ok().as_deref() {
5044        Some("off") => return false,
5045        Some("on" | "stream") => return true,
5046        _ => {}
5047    }
5048    crate::Config::resolve()
5049        .ok()
5050        .and_then(|c| c.tui)
5051        .map(|t| t.thinking == crate::ThinkingMode::Stream)
5052        .unwrap_or(true)
5053}
5054
5055/// Spinner glyph frames (braille) for the thinking renderer.
5056const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5057
5058/// The ephemeral spinner line text. Pure for testing. A braille spinner that
5059/// advances every frame (so the line is visibly alive even while only the clock
5060/// moves), a stage `label` telling the user what's happening right now
5061/// (`thinking…`, `compressing context…`, …), and the elapsed seconds. The
5062/// `· N chars` tail is shown only once generation has produced output
5063/// (`chars > 0`).
5064fn format_spinner(frame: usize, secs: f32, label: &str, chars: usize) -> String {
5065    let braille = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()];
5066    if chars == 0 {
5067        format!("{braille} {label} {secs:.1}s")
5068    } else {
5069        format!("{braille} {label} {secs:.1}s · {chars} chars")
5070    }
5071}
5072
5073/// Cargo-style thinking renderer: a model's reasoning (#385 — normally
5074/// suppressed) streams as DIM scrolled lines that stay in scrollback, with one
5075/// **ephemeral** spinner line pinned at the bottom (`\r`-redrawn, cleared with
5076/// `\x1b[K` — one line, never a region; the plain-scroller carve-out), showing
5077/// elapsed + size. Erased the instant the answer begins. Constructed only when
5078/// the caller opts in (TTY); headless never builds it.
5079struct ThinkingSpinner {
5080    color: bool,
5081    frame: usize,
5082    start: std::time::Instant,
5083    chars: usize,
5084    line_buf: String,
5085    spinner_drawn: bool,
5086}
5087
5088impl ThinkingSpinner {
5089    fn new(color: bool) -> Self {
5090        Self {
5091            color,
5092            frame: 0,
5093            start: std::time::Instant::now(),
5094            chars: 0,
5095            line_buf: String::new(),
5096            spinner_drawn: false,
5097        }
5098    }
5099
5100    /// Feed a reasoning chunk: flush completed lines as dim scrollback, then
5101    /// redraw the bottom spinner.
5102    fn reasoning(&mut self, chunk: &str) {
5103        self.chars += chunk.chars().count();
5104        self.line_buf.push_str(chunk);
5105        while let Some(nl) = self.line_buf.find('\n') {
5106            let line: String = self.line_buf.drain(..=nl).collect();
5107            self.print_dim_line(line.trim_end_matches(['\n', '\r']));
5108        }
5109        self.redraw_spinner();
5110    }
5111
5112    fn print_dim_line(&mut self, line: &str) {
5113        self.erase_spinner();
5114        if line.trim().is_empty() {
5115            return;
5116        }
5117        let mut out = io::stdout();
5118        if self.color {
5119            let _ = execute!(
5120                out,
5121                SetForegroundColor(CtColor::DarkGrey),
5122                Print("  "),
5123                Print(line),
5124                ResetColor,
5125                Print("\n"),
5126            );
5127        } else {
5128            let _ = writeln!(out, "  {line}");
5129        }
5130    }
5131
5132    fn redraw_spinner(&mut self) {
5133        self.frame = self.frame.wrapping_add(1);
5134        let line = format_spinner(
5135            self.frame,
5136            self.start.elapsed().as_secs_f32(),
5137            "thinking…",
5138            self.chars,
5139        );
5140        // The spinner is redrawn in place with `\r` and never scrolls, so it
5141        // must not exceed the terminal width — a wrapped spinner leaves stale
5142        // rows behind. Truncate to width with a faded `…` tail.
5143        let fitted = display::fit_line(&line, display::term_cols());
5144        let mut out = io::stdout();
5145        if self.color {
5146            let _ = execute!(
5147                out,
5148                Print("\r\x1b[K"),
5149                SetForegroundColor(CtColor::DarkGrey),
5150                Print(&fitted.head),
5151                SetForegroundColor(display::FADE_CT),
5152                Print(&fitted.fade),
5153                Print(fitted.ellipsis),
5154                ResetColor,
5155            );
5156        } else {
5157            let _ = write!(out, "\r{}{}{}", fitted.head, fitted.fade, fitted.ellipsis);
5158        }
5159        let _ = out.flush();
5160        self.spinner_drawn = true;
5161    }
5162
5163    fn erase_spinner(&mut self) {
5164        if self.spinner_drawn {
5165            let _ = write!(io::stdout(), "\r\x1b[K");
5166            let _ = io::stdout().flush();
5167            self.spinner_drawn = false;
5168        }
5169    }
5170
5171    /// The answer is starting (or the stream ended): flush trailing reasoning
5172    /// and erase the spinner so output flows on cleanly. Idempotent.
5173    fn finish(&mut self) {
5174        if !self.line_buf.is_empty() {
5175            let tail = std::mem::take(&mut self.line_buf);
5176            self.print_dim_line(tail.trim_end_matches(['\n', '\r']));
5177        }
5178        self.erase_spinner();
5179    }
5180}
5181
5182/// Stream an Ollama NDJSON response, printing tokens as they arrive.
5183/// Returns `(accumulated_text, token_usage)`.
5184/// Token usage is extracted from the final chunk (`done: true`).
5185/// `show_thinking` opts into the cargo-style reasoning spinner (TTY only).
5186async fn stream_response(
5187    resp: reqwest::Response,
5188    color: bool,
5189    show_thinking: bool,
5190    leading_reasoning: bool,
5191    cancel: Option<&std::sync::atomic::AtomicBool>,
5192    markdown: bool,
5193) -> anyhow::Result<(String, Option<crate::TokenUsage>)> {
5194    let mut spinner = show_thinking.then(|| ThinkingSpinner::new(color));
5195    let mut full = String::new();
5196    let mut started = false;
5197    let mut usage: Option<crate::TokenUsage> = None;
5198    // Step 25.3 (#568): when markdown is active, route the *visible* token stream
5199    // through the block-aware writer (inline lines render per completed line;
5200    // fences/tables hold until they close). The accumulated `full` stays RAW —
5201    // it is persisted and re-sent to the model, so it must carry no ANSI. The
5202    // caller gates `markdown` on `color`, so the writer renders with `color: true`.
5203    let cols = display::term_cols();
5204    let mut md =
5205        markdown.then(|| MarkdownStreamWriter::new(io::stdout(), RenderOpts { color: true, cols }));
5206    // #385: suppress inline <think>…</think> reasoning from the live stream + the
5207    // accumulated reply, even when a tag is split across token boundaries.
5208    // #528: models that emit a lone leading `</think>` (no opener) start the
5209    // filter *inside* the reasoning block so the closer + reasoning don't leak.
5210    let mut think = if leading_reasoning {
5211        crate::reasoning::ThinkFilter::with_leading_reasoning()
5212    } else {
5213        crate::reasoning::ThinkFilter::new()
5214    };
5215
5216    let mut resp = resp;
5217    // Race each chunk read against the interrupt flag so Esc stops the token
5218    // stream promptly; on interrupt, stop reading and return what we have.
5219    while let Some(chunk) = match cancellable(cancel, resp.chunk()).await {
5220        Some(c) => c?,
5221        None => None,
5222    } {
5223        let text = String::from_utf8_lossy(&chunk);
5224        for line in text.lines() {
5225            if line.is_empty() {
5226                continue;
5227            }
5228            let Ok(json) = serde_json::from_str::<serde_json::Value>(line) else {
5229                continue;
5230            };
5231            let raw = json["message"]["content"].as_str().unwrap_or("");
5232            let (token, reasoning) = think.feed_split(raw);
5233            // Surface reasoning live (cargo-style) — both the inline `<think>`
5234            // span the filter just split out AND any separate `thinking` field.
5235            if let Some(sp) = spinner.as_mut() {
5236                if !reasoning.is_empty() {
5237                    sp.reasoning(&reasoning);
5238                }
5239                if let Some(t) = json["message"]["thinking"].as_str() {
5240                    if !t.is_empty() {
5241                        sp.reasoning(t);
5242                    }
5243                }
5244            }
5245            let token = token.as_str();
5246            if !token.is_empty() {
5247                if !started {
5248                    // The answer is starting — tear the spinner down first.
5249                    if let Some(sp) = spinner.as_mut() {
5250                        sp.finish();
5251                    }
5252                    if color {
5253                        execute!(
5254                            io::stdout(),
5255                            SetForegroundColor(NEWT_ORANGE_CT),
5256                            Print("▸  "),
5257                            ResetColor,
5258                        )
5259                        .ok();
5260                    } else {
5261                        print!("▸  ");
5262                    }
5263                    started = true;
5264                }
5265                if let Some(w) = md.as_mut() {
5266                    w.push(token).ok();
5267                } else {
5268                    print!("{token}");
5269                    io::stdout().flush().ok();
5270                }
5271                full.push_str(token);
5272            }
5273            if json["done"].as_bool().unwrap_or(false) {
5274                // Extract token counts from the final Ollama chunk.
5275                let input = json["prompt_eval_count"].as_u64().map(|n| n as u32);
5276                let output = json["eval_count"].as_u64().map(|n| n as u32);
5277                usage = input.zip(output).map(|(i, o)| crate::TokenUsage {
5278                    input_tokens: i,
5279                    output_tokens: o,
5280                });
5281                break;
5282            }
5283        }
5284    }
5285    // #385: flush any clean tail the filter held back (a trailing run that turned out
5286    // not to be the start of a `<think>` tag).
5287    let tail = think.finish();
5288    if !tail.is_empty() {
5289        if !started {
5290            if let Some(sp) = spinner.as_mut() {
5291                sp.finish();
5292            }
5293            print!("▸  ");
5294            started = true;
5295        }
5296        if let Some(w) = md.as_mut() {
5297            w.push(&tail).ok();
5298        } else {
5299            print!("{tail}");
5300            io::stdout().flush().ok();
5301        }
5302        full.push_str(&tail);
5303    }
5304    // All-reasoning response (no clean content): tear the spinner down anyway so
5305    // the terminal isn't left mid-spinner.
5306    if let Some(sp) = spinner.as_mut() {
5307        sp.finish();
5308    }
5309    // The markdown writer newline-terminates each line it emits, so it owns the
5310    // trailing newline; only the raw path needs the closing `println!`.
5311    if let Some(w) = md.as_mut() {
5312        w.finish().ok();
5313    }
5314    if started && md.is_none() {
5315        println!();
5316    }
5317    Ok((full, usage))
5318}
5319
5320#[cfg(test)]
5321mod repeat_call_guard_tests {
5322    use super::*;
5323
5324    #[test]
5325    fn short_circuits_exact_repeat_and_escalates() {
5326        let mut g = RepeatCallGuard::default();
5327        let args = serde_json::json!({"command": "mkdir x"});
5328        // First sight of the call → let it run (no steer).
5329        assert!(g.repeat_steer("run_command", &args).is_none());
5330        // After a failure, an exact repeat is steered, quoting the prior error.
5331        g.record("run_command", &args, false, "error: shell unavailable");
5332        let s = g.repeat_steer("run_command", &args).expect("repeat steers");
5333        assert!(s.contains("already called"), "{s}");
5334        assert!(s.contains("error: shell unavailable"), "{s}");
5335        assert!(
5336            !s.contains("stop using"),
5337            "one failure → no escalation yet: {s}"
5338        );
5339        // A second (distinct-args) failure of the same tool crosses ESCALATE_AFTER.
5340        g.record(
5341            "run_command",
5342            &serde_json::json!({"command": "ls"}),
5343            false,
5344            "error: denied",
5345        );
5346        let s2 = g.repeat_steer("run_command", &args).expect("still steers");
5347        assert!(s2.contains("stop using"), "escalates: {s2}");
5348    }
5349
5350    #[test]
5351    fn ignores_successes_and_distinct_calls() {
5352        let mut g = RepeatCallGuard::default();
5353        let a = serde_json::json!({"path": "f.rs"});
5354        g.record("read_file", &a, true, "file contents"); // success → not remembered
5355        assert!(g.repeat_steer("read_file", &a).is_none());
5356        // A failure under different args does not short-circuit a distinct call.
5357        let b = serde_json::json!({"path": "g.rs"});
5358        g.record("read_file", &b, false, "error reading g.rs");
5359        assert!(
5360            g.repeat_steer("read_file", &a).is_none(),
5361            "distinct args still run"
5362        );
5363        assert!(g.repeat_steer("read_file", &b).is_some());
5364    }
5365
5366    #[test]
5367    fn steers_no_result_repeats_on_second_issuance() {
5368        // #718: a success-shaped no-result that the model re-issues byte-for-byte
5369        // is steered on its 2nd call — distinct from a hard failure (no escalation),
5370        // distinct from a genuine success (which is never steered).
5371        let mut g = RepeatCallGuard::default();
5372
5373        // recall "no matches" — first sight runs; record it; the identical 2nd
5374        // issuance is steered before re-execution.
5375        let q = serde_json::json!({"query": "newt-tui PyO3 bindings"});
5376        assert!(
5377            g.repeat_steer("recall", &q).is_none(),
5378            "first recall must run"
5379        );
5380        g.record(
5381            "recall",
5382            &q,
5383            true,
5384            "no matches in past conversations for \"newt-tui PyO3 bindings\" — try different keywords.",
5385        );
5386        let s = g
5387            .repeat_steer("recall", &q)
5388            .expect("2nd identical recall steers");
5389        assert!(s.contains("no matches"), "{s}");
5390        assert!(
5391            s.contains("resume_context"),
5392            "recall steer points at resume_context: {s}"
5393        );
5394        assert!(
5395            !s.contains("stop using"),
5396            "a no-result is not a hard failure — no escalation: {s}"
5397        );
5398
5399        // state_get "no such key" — same: 2nd identical probe is steered.
5400        let k = serde_json::json!({"key": "current_task"});
5401        assert!(g.repeat_steer("state_get", &k).is_none());
5402        g.record("state_get", &k, true, "no such key: current_task");
5403        assert!(
5404            g.repeat_steer("state_get", &k).is_some(),
5405            "2nd identical state_get steers"
5406        );
5407
5408        // plan_get empty ledger — same: the second identical read is steered
5409        // toward creating the missing plan instead of polling the empty ledger.
5410        let empty_plan_args = serde_json::json!({});
5411        assert!(g.repeat_steer("plan_get", &empty_plan_args).is_none());
5412        g.record(
5413            "plan_get",
5414            &empty_plan_args,
5415            true,
5416            "no active plan — if this is multi-step work, call update_plan next",
5417        );
5418        let plan_steer = g
5419            .repeat_steer("plan_get", &empty_plan_args)
5420            .expect("2nd identical empty plan_get steers");
5421        assert!(plan_steer.contains("update_plan"), "{plan_steer}");
5422
5423        // A genuine success with content is still NEVER steered on repeat.
5424        let f = serde_json::json!({"path": "f.rs"});
5425        g.record("read_file", &f, true, "file contents");
5426        assert!(g.repeat_steer("read_file", &f).is_none());
5427
5428        // A no-result under DIFFERENT args is a distinct call — let it run.
5429        let q2 = serde_json::json!({"query": "something else entirely"});
5430        assert!(
5431            g.repeat_steer("recall", &q2).is_none(),
5432            "distinct recall args still run"
5433        );
5434    }
5435
5436    #[test]
5437    fn steers_duplicate_successful_web_fetch() {
5438        let mut g = RepeatCallGuard::default();
5439        let issue = serde_json::json!({
5440            "url": "https://github.com/Gilamonster-Foundation/newt-agent/issues/771"
5441        });
5442
5443        assert!(
5444            g.repeat_steer("web_fetch", &issue).is_none(),
5445            "first fetch must run"
5446        );
5447        g.record("web_fetch", &issue, true, "# Issue\n\nbody");
5448        let steer = g
5449            .repeat_steer("web_fetch", &issue)
5450            .expect("2nd identical successful fetch steers");
5451        assert!(steer.contains("already observed"), "{steer}");
5452        assert!(steer.contains("`web_fetch`"), "{steer}");
5453        assert!(
5454            steer.contains("https://github.com/Gilamonster-Foundation/newt-agent/issues/771"),
5455            "{steer}"
5456        );
5457        assert!(
5458            g.repeat_steer(
5459                "web_fetch",
5460                &serde_json::json!({"url": "https://github.com/hartsock/scrybe"})
5461            )
5462            .is_none(),
5463            "distinct URLs still run"
5464        );
5465
5466        let file = serde_json::json!({"path": "src/lib.rs"});
5467        g.record("read_file", &file, true, "file contents");
5468        assert!(
5469            g.repeat_steer("read_file", &file).is_none(),
5470            "ordinary successful reads are still not steered"
5471        );
5472    }
5473
5474    #[test]
5475    fn steers_duplicate_successful_read_only_run_command() {
5476        let mut g = RepeatCallGuard::default();
5477        let args = serde_json::json!({
5478            "command": "grep -n 'help_lines' /Users/shawnhartsock/workspaces/newt-agent/newt-tui/src/lib.rs"
5479        });
5480
5481        assert!(
5482            g.repeat_steer("run_command", &args).is_none(),
5483            "first grep should run"
5484        );
5485        g.record(
5486            "run_command",
5487            &args,
5488            true,
5489            "9439:fn help_lines() -> &'static [&'static str] {",
5490        );
5491
5492        let steer = g
5493            .repeat_steer("run_command", &args)
5494            .expect("second identical grep should steer");
5495        assert!(steer.contains("already observed"), "{steer}");
5496        assert!(steer.contains("read-only shell probe"), "{steer}");
5497        assert!(steer.contains("`run_command`"), "{steer}");
5498        assert!(steer.contains("grep -n"), "{steer}");
5499        assert!(steer.contains("Do NOT repeat"), "{steer}");
5500    }
5501
5502    #[test]
5503    fn does_not_steer_successful_write_capable_run_command() {
5504        let mut g = RepeatCallGuard::default();
5505        let args = serde_json::json!({"command": "cargo test -p newt-tui"});
5506
5507        g.record("run_command", &args, true, "test result: ok");
5508
5509        assert!(
5510            g.repeat_steer("run_command", &args).is_none(),
5511            "successful build/test commands are still repeatable"
5512        );
5513    }
5514
5515    #[test]
5516    fn classifier_leaves_ordinary_successes_repeatable() {
5517        let file = serde_json::json!({"path": "src/lib.rs"});
5518        assert_eq!(
5519            RepeatCallGuard::classify_repeat_memo("read_file", &file, true, "file contents"),
5520            None
5521        );
5522
5523        let tests = serde_json::json!({"command": "cargo test -p newt-core"});
5524        assert_eq!(
5525            RepeatCallGuard::classify_repeat_memo("run_command", &tests, true, "test result: ok"),
5526            None
5527        );
5528
5529        let mut g = RepeatCallGuard::default();
5530        g.record("read_file", &file, true, "file contents");
5531        g.record("run_command", &tests, true, "test result: ok");
5532        assert!(
5533            g.repeat_memos.is_empty(),
5534            "ordinary successful calls must stay repeatable"
5535        );
5536    }
5537
5538    #[test]
5539    fn workflow_error_fingerprint_captures_cargo_location() {
5540        let output = r#"
5541error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5542   --> newt-tui/src/help_sections.rs:523:22
5543    |
5544523 |         lines: SECTION_PROMPT_TOKENS,
5545    |                ^^^^^^^^^^^^^^^^^^^^^ help: a static with a similar name exists: `SECTION_PROMPT`
5546"#;
5547
5548        let fp = build_error_fingerprint(output).expect("cargo error should fingerprint");
5549
5550        assert!(fp.contains("newt-tui/src/help_sections.rs:523:22"), "{fp}");
5551        assert!(fp.contains("error[E0425]"), "{fp}");
5552        assert!(fp.contains("SECTION_PROMPT_TOKENS"), "{fp}");
5553    }
5554
5555    #[test]
5556    fn workflow_runtime_nudges_after_error_without_writes() {
5557        let output = r#"
5558error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5559   --> newt-tui/src/help_sections.rs:523:22
5560"#;
5561        let mut state = WorkflowRuntimeState::default();
5562
5563        state.record_tool_result(output);
5564        state.record_round_outcome(false, false);
5565
5566        let nudge = state
5567            .round_start_nudge(None)
5568            .expect("read-only round after evidence should lock the active repair");
5569        assert!(nudge.contains("<workflow_state>"), "{nudge}");
5570        assert!(
5571            nudge.contains("newt-tui/src/help_sections.rs:523:22"),
5572            "{nudge}"
5573        );
5574        assert!(nudge.contains("next_allowed_actions"), "{nudge}");
5575        assert!(nudge.contains("disallowed_actions"), "{nudge}");
5576
5577        let classification = crate::NudgeClassification {
5578            class: crate::NudgeClass::PlanUpdate,
5579            score: 1.0,
5580        };
5581        let rediscovery = state
5582            .rediscovery_nudge(
5583                Some(&classification),
5584                "Summary of Findings\nRoot Cause: the build failure is still present.",
5585                None,
5586            )
5587            .expect("classified summary should be steered toward action");
5588        assert!(
5589            rediscovery.contains("Do not restate findings"),
5590            "{rediscovery}"
5591        );
5592        assert!(
5593            rediscovery.contains("newt-tui/src/help_sections.rs:523:22"),
5594            "{rediscovery}"
5595        );
5596    }
5597
5598    #[test]
5599    fn workflow_runtime_tracks_failed_edit_as_unresolved_evidence() {
5600        let output = "error: old_string not found in newt-tui/src/help_sections.rs";
5601        let mut state = WorkflowRuntimeState::default();
5602
5603        state.record_tool_result(output);
5604        state.record_round_outcome(false, false);
5605
5606        let nudge = state
5607            .round_start_nudge(None)
5608            .expect("failed edit should remain unresolved repair evidence");
5609        assert!(nudge.contains("old_string not found"), "{nudge}");
5610
5611        let grace = state
5612            .cap_grace_nudge(None, 25, 5)
5613            .expect("cap after failed edit/read-only recovery should grant an action round");
5614        assert!(
5615            grace.contains("configured_workflow_grace_rounds = 5"),
5616            "{grace}"
5617        );
5618        assert!(
5619            grace.contains("call edit_file or write_file now"),
5620            "{grace}"
5621        );
5622        assert!(
5623            state.cap_grace_nudge(None, 25, 0).is_none(),
5624            "configured zero grace disables soft cap extension"
5625        );
5626
5627        state.record_round_outcome(true, true);
5628        let verify = state
5629            .cap_grace_nudge(None, 25, 3)
5630            .expect("a successful edit at the cap should get a verification window");
5631        assert!(verify.contains("focused verification"), "{verify}");
5632        assert!(
5633            verify.contains("configured_workflow_grace_rounds = 3"),
5634            "{verify}"
5635        );
5636    }
5637
5638    #[test]
5639    fn workflow_runtime_grants_configured_grace_for_recent_plan_progress() {
5640        let ledger = SessionStepLedger::default();
5641        ledger.set_plan(&["finish round-cap grace".to_string(), "verify".to_string()]);
5642        let mut state = WorkflowRuntimeState::default();
5643
5644        state.record_round_outcome(false, true);
5645
5646        let nudge = state
5647            .cap_grace_nudge(Some(&ledger), 2, 4)
5648            .expect("recent active-plan progress should activate configured grace");
5649        assert!(
5650            nudge.contains("configured_workflow_grace_rounds = 4"),
5651            "{nudge}"
5652        );
5653        assert!(nudge.contains("finish round-cap grace"), "{nudge}");
5654        assert!(
5655            state.cap_grace_nudge(Some(&ledger), 2, 0).is_none(),
5656            "zero configured grace keeps the cap hard"
5657        );
5658    }
5659
5660    /// #<issue>: a diagnostic workflow (e.g. `diagnose_failure.toml`,
5661    /// `progress_horizon_rounds = 6`) legitimately spends more read-only
5662    /// rounds between plan checkpoints than a routine edit does. Without a
5663    /// horizon override, 4 rounds since the last checkpoint already exceeds
5664    /// the shared default (`WORKFLOW_RECENT_PROGRESS_ROUNDS = 3`) and grace
5665    /// does NOT activate — RED on the pre-fix behavior. Setting the override
5666    /// widens the window so the same 4-rounds-stale state still counts as
5667    /// "recent" — GREEN.
5668    #[test]
5669    fn progress_horizon_override_widens_the_recent_progress_window() {
5670        let ledger = SessionStepLedger::default();
5671        ledger.set_plan(&["diagnose the failure".to_string(), "fix it".to_string()]);
5672
5673        let mut default_horizon = WorkflowRuntimeState::default();
5674        default_horizon.record_round_outcome(false, true); // a checkpoint...
5675        for _ in 0..4 {
5676            default_horizon.record_round_outcome(false, false); // ...then 4 idle rounds
5677        }
5678        assert!(
5679            default_horizon
5680                .cap_grace_nudge(Some(&ledger), 2, 4)
5681                .is_none(),
5682            "4 rounds since the last checkpoint exceeds the default 3-round horizon"
5683        );
5684
5685        let mut widened = WorkflowRuntimeState::default();
5686        widened.set_progress_horizon(Some(6));
5687        widened.record_round_outcome(false, true);
5688        for _ in 0..4 {
5689            widened.record_round_outcome(false, false);
5690        }
5691        assert!(
5692            widened.cap_grace_nudge(Some(&ledger), 2, 4).is_some(),
5693            "a widened 6-round horizon still treats 4-rounds-stale as recent progress"
5694        );
5695    }
5696
5697    #[test]
5698    fn workspace_write_classifier_is_narrow() {
5699        assert!(is_workspace_write_call("edit_file"));
5700        assert!(is_workspace_write_call("write_file"));
5701        assert!(!is_workspace_write_call("run_command"));
5702        assert!(!is_workspace_write_call("read_file"));
5703    }
5704
5705    #[test]
5706    fn no_result_reason_classifies_and_routes() {
5707        // recall / state_get no-result prefixes classify…
5708        assert!(RepeatCallGuard::no_result_reason(
5709            "recall",
5710            "no matches in past conversations for \"x\" — try different keywords."
5711        )
5712        .is_some_and(|r| r.contains("no matches") && r.contains("resume_context")));
5713        assert!(
5714            RepeatCallGuard::no_result_reason("state_get", "no such key: current_task")
5715                .is_some_and(|r| r.contains("not set"))
5716        );
5717        assert!(
5718            RepeatCallGuard::no_result_reason("plan_get", "no active plan — call update_plan")
5719                .is_some_and(|r| r.contains("update_plan"))
5720        );
5721        // …a real success with content does not.
5722        assert!(
5723            RepeatCallGuard::no_result_reason("recall", "3 match(es) in past conversations")
5724                .is_none()
5725        );
5726        assert!(RepeatCallGuard::no_result_reason("read_file", "file contents").is_none());
5727
5728        // A recall ERROR (ok=false) goes through the FAILURE path, not no-result
5729        // classification: it lands in repeat_memos as escalation-eligible.
5730        let mut g = RepeatCallGuard::default();
5731        let q = serde_json::json!({"query": "x"});
5732        g.record("recall", &q, false, "error: index unavailable");
5733        assert!(matches!(
5734            g.repeat_memos.get(&RepeatCallGuard::key("recall", &q)),
5735            Some(RepeatMemo::Failure { first_line }) if first_line == "error: index unavailable"
5736        ));
5737    }
5738
5739    #[test]
5740    fn first_line_caps_and_takes_first() {
5741        assert_eq!(first_line("one\ntwo\nthree"), "one");
5742        assert_eq!(first_line(""), "");
5743        assert_eq!(first_line(&"x".repeat(500)).chars().count(), 200);
5744    }
5745}
5746
5747#[cfg(test)]
5748mod cap_exit_unit_tests {
5749    use super::*;
5750
5751    #[test]
5752    fn cap_exit_nudge_names_the_limit_and_folds_in_progress() {
5753        let nudge = cap_exit_nudge(5, None, &[]);
5754        assert!(nudge.contains("5 rounds"), "got: {nudge}");
5755        assert!(nudge.contains("Do NOT call any more tools"));
5756        // #867: the grounding constraint — the trim just deleted the evidence,
5757        // so the nudge must forbid reconstructing paths from memory.
5758        assert!(
5759            nudge.contains("Cite only file paths that appear verbatim"),
5760            "got: {nudge}"
5761        );
5762        assert!(nudge.contains("say so plainly"), "got: {nudge}");
5763        assert!(
5764            !nudge.contains("progress so far"),
5765            "no block when None: {nudge}"
5766        );
5767        assert!(
5768            !nudge.contains("actually observed"),
5769            "no manifest block when the ledger is empty: {nudge}"
5770        );
5771        // Step 27.5: the <plan>/<state> progress is folded into the nudge.
5772        let with = cap_exit_nudge(5, Some("<plan>1. [x] foo</plan>"), &[]);
5773        assert!(with.contains("Your progress so far"), "got: {with}");
5774        assert!(with.contains("<plan>1. [x] foo</plan>"), "got: {with}");
5775    }
5776
5777    /// #867 Part A: the observed-paths manifest survives the trim and is
5778    /// handed to the model as the citable ground truth.
5779    #[test]
5780    fn cap_exit_nudge_folds_in_the_observed_paths_manifest() {
5781        let observed = vec![
5782            "newt-tui/src/lib.rs".to_string(),
5783            "newt-core/src/agentic/mod.rs".to_string(),
5784        ];
5785        let nudge = cap_exit_nudge(5, Some("<state>k=v</state>"), &observed);
5786        assert!(
5787            nudge.contains("File paths actually observed in tool results"),
5788            "got: {nudge}"
5789        );
5790        assert!(nudge.contains("- newt-tui/src/lib.rs"), "got: {nudge}");
5791        assert!(
5792            nudge.contains("- newt-core/src/agentic/mod.rs"),
5793            "got: {nudge}"
5794        );
5795        // Manifest precedes the progress block; both survive together.
5796        let manifest_at = nudge.find("actually observed").unwrap();
5797        let progress_at = nudge.find("Your progress so far").unwrap();
5798        assert!(manifest_at < progress_at, "got: {nudge}");
5799    }
5800
5801    #[test]
5802    fn cap_exit_progress_renders_plan_and_state_or_none() {
5803        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
5804        use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
5805        let ledger = SessionStepLedger::default();
5806        let pad = SessionScratchpadStore::default();
5807        // Both empty → nothing to salvage.
5808        assert!(cap_exit_progress(Some(&ledger), Some(&pad)).is_none());
5809        assert!(cap_exit_progress(None, None).is_none());
5810        // Populated → a combined block naming both.
5811        ledger.set_plan(&["build it".to_string(), "test it".to_string()]);
5812        pad.set("cwd", "/work".to_string());
5813        let p = cap_exit_progress(
5814            Some(&ledger as &dyn StepLedger),
5815            Some(&pad as &dyn ScratchpadStore),
5816        )
5817        .expect("non-empty progress");
5818        assert!(p.contains("build it"), "{p}");
5819        assert!(p.contains("cwd"), "{p}");
5820    }
5821
5822    #[test]
5823    fn spinner_line_formats_and_frame_wraps() {
5824        // Braille spinner + stage label + clock; the chars tail shows once
5825        // generation has produced output.
5826        assert_eq!(
5827            format_spinner(0, 1.23, "thinking…", 340),
5828            "⠋ thinking… 1.2s · 340 chars"
5829        );
5830        // A different stage label, and chars == 0 drops the `· N chars` tail.
5831        assert_eq!(
5832            format_spinner(1, 0.5, "compressing context…", 0),
5833            "⠙ compressing context… 0.5s"
5834        );
5835        // Frame index wraps over the braille glyph set.
5836        assert!(
5837            format_spinner(SPINNER_FRAMES.len(), 0.0, "thinking…", 0).contains(SPINNER_FRAMES[0])
5838        );
5839    }
5840
5841    #[test]
5842    fn cap_exit_fallback_usage_advice_and_salvage() {
5843        // wasted_calls < rounds → the standard "raise max_tool_rounds" advice.
5844        let with = cap_exit_fallback(
5845            4,
5846            Some(crate::TokenUsage {
5847                input_tokens: 12,
5848                output_tokens: 34,
5849            }),
5850            0,
5851            None,
5852        );
5853        assert!(with.contains("12 in / 34 out tokens"), "got: {with}");
5854        assert!(with.contains("max_tool_rounds"), "got: {with}");
5855
5856        let without = cap_exit_fallback(4, None, 0, None);
5857        assert!(!without.contains("tokens consumed"), "got: {without}");
5858        assert!(without.contains("tool-call limit of 4"), "got: {without}");
5859
5860        // Step 27.5: a thrash run (≥ one failed call per round) gets HONEST
5861        // advice — a tooling problem, not "raise the cap".
5862        let thrash = cap_exit_fallback(4, None, 6, None);
5863        assert!(thrash.contains("tool calls that failed"), "got: {thrash}");
5864        assert!(
5865            !thrash.contains("raise [tui].max_tool_rounds"),
5866            "thrash advice must not blame the cap: {thrash}"
5867        );
5868
5869        // Step 27.5: progress is salvaged even when the summary failed.
5870        let salvaged = cap_exit_fallback(4, None, 0, Some("<state>cwd=/x</state>"));
5871        assert!(salvaged.contains("Progress captured"), "got: {salvaged}");
5872        assert!(
5873            salvaged.contains("<state>cwd=/x</state>"),
5874            "got: {salvaged}"
5875        );
5876    }
5877
5878    #[test]
5879    fn cap_exit_summary_action_handoff_is_rejected() {
5880        let handoff = "I have two issues: duplicate topic_has_rollups and a stray brace. Let me fix both — read around 490 to see what needs removing, then verify with a build check.";
5881        assert!(cap_exit_summary_is_action_handoff(handoff));
5882        assert!(!cap_exit_summary_is_action_handoff(
5883            "The duplicate helper definitions and stray brace were removed, and the build check passed."
5884        ));
5885
5886        let fallback = cap_exit_action_handoff_fallback(
5887            25,
5888            None,
5889            2,
5890            Some("<plan>1. [ ] fix duplicate helper definitions</plan>"),
5891        );
5892        assert!(fallback.contains("tool-call limit of 25"), "{fallback}");
5893        assert!(
5894            fallback.contains("described future tool actions"),
5895            "{fallback}"
5896        );
5897        assert!(
5898            fallback.contains("preserved the verified progress"),
5899            "{fallback}"
5900        );
5901        assert!(
5902            !fallback.contains("final summarization request also failed"),
5903            "{fallback}"
5904        );
5905        assert!(
5906            fallback.contains("Progress captured at the tool-call limit"),
5907            "{fallback}"
5908        );
5909    }
5910
5911    #[test]
5912    fn read_only_tools_classified_correctly() {
5913        // save_note writes memory, not the workspace: a round that only
5914        // saved a note must still count toward the read-only write-nudge.
5915        for name in &[
5916            "list_dir",
5917            "read_file",
5918            "find",
5919            "search",
5920            "web_fetch",
5921            "use_skill",
5922            "save_note",
5923        ] {
5924            assert!(is_read_only_tool(name), "{name} should be read-only");
5925        }
5926    }
5927
5928    #[test]
5929    fn write_tools_not_read_only() {
5930        for name in &["edit_file", "write_file", "run_command"] {
5931            assert!(!is_read_only_tool(name), "{name} should NOT be read-only");
5932        }
5933    }
5934
5935    #[test]
5936    fn read_only_call_classifies_simple_shell_probes() {
5937        assert!(is_read_only_call(
5938            "run_command",
5939            &serde_json::json!({"command": "grep -n 'help_lines' newt-tui/src/lib.rs"})
5940        ));
5941        assert!(is_read_only_call(
5942            "run_command",
5943            &serde_json::json!({"command": "rg -n format_help newt-tui/src"})
5944        ));
5945        assert!(is_read_only_call(
5946            "run_command",
5947            &serde_json::json!({"command": "sed -n '1,20p' newt-tui/src/lib.rs"})
5948        ));
5949
5950        assert!(!is_read_only_call(
5951            "run_command",
5952            &serde_json::json!({"command": "cargo test -p newt-tui"})
5953        ));
5954        assert!(!is_read_only_call(
5955            "run_command",
5956            &serde_json::json!({"command": "sed -i 's/a/b/' file.txt"})
5957        ));
5958        assert!(!is_read_only_call(
5959            "run_command",
5960            &serde_json::json!({"command": "grep x file > out.txt"})
5961        ));
5962    }
5963
5964    #[test]
5965    fn read_only_action_nudge_names_edit_permission_and_blocker_paths() {
5966        let nudge = read_only_action_nudge(3, 4, None, None);
5967        assert!(nudge.contains("read-only rounds so far"), "{nudge}");
5968        assert!(nudge.contains("edit_file"), "{nudge}");
5969        assert!(nudge.contains("write_file"), "{nudge}");
5970        assert!(nudge.contains("request_permissions"), "{nudge}");
5971        assert!(nudge.contains("exact blocker"), "{nudge}");
5972    }
5973
5974    #[test]
5975    fn read_only_action_nudge_mentions_active_plan_when_present() {
5976        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
5977
5978        let ledger = SessionStepLedger::default();
5979        ledger.restore(&PlanSnapshot {
5980            steps: vec![
5981                Step {
5982                    description: "inspect".to_string(),
5983                    status: StepStatus::Done,
5984                },
5985                Step {
5986                    description: "edit".to_string(),
5987                    status: StepStatus::Active,
5988                },
5989            ],
5990        });
5991        let nudge = read_only_action_nudge(3, 2, Some(&ledger as &dyn StepLedger), None);
5992        assert!(nudge.contains("active multi-step plan"), "{nudge}");
5993        assert!(nudge.contains("ACTIVE step"), "{nudge}");
5994    }
5995
5996    /// #<issue>: when a `WorkflowSteerer` match offers a delegate hint (e.g.
5997    /// the built-in `diagnose_failure` workflow, and `crew`/`team` dispatch is
5998    /// available this session), the read-only nudge surfaces it — sustained
5999    /// read-only exploration on that task shape is exactly what delegation is
6000    /// for, not just "stop reading, edit it yourself".
6001    #[test]
6002    fn read_only_action_nudge_includes_a_delegate_hint_when_offered() {
6003        let nudge = read_only_action_nudge(3, 4, None, Some("consider calling crew or team"));
6004        assert!(nudge.contains("consider calling crew or team"), "{nudge}");
6005        // Still carries the original inline-action guidance too — delegation
6006        // is offered ALONGSIDE continuing directly, never in place of it.
6007        assert!(nudge.contains("edit_file"), "{nudge}");
6008    }
6009
6010    #[test]
6011    fn read_only_action_nudge_omits_delegate_clause_when_none_offered() {
6012        let nudge = read_only_action_nudge(3, 4, None, None);
6013        assert!(!nudge.contains("crew"), "{nudge}");
6014        assert!(!nudge.contains("team"), "{nudge}");
6015    }
6016
6017    #[test]
6018    fn pending_plan_completion_nudge_is_state_driven() {
6019        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6020
6021        assert!(pending_plan_completion_nudge(None, false, None).is_none());
6022
6023        let ledger = SessionStepLedger::default();
6024        ledger.restore(&PlanSnapshot {
6025            steps: vec![
6026                Step {
6027                    description: "already done".to_string(),
6028                    status: StepStatus::Done,
6029                },
6030                Step {
6031                    description: "keep working".to_string(),
6032                    status: StepStatus::Active,
6033                },
6034            ],
6035        });
6036        let nudge = pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None)
6037            .expect("open plan produces a nudge");
6038        assert!(nudge.contains("1/2 unfinished step"), "{nudge}");
6039        assert!(nudge.contains("Active step: 'keep working'"), "{nudge}");
6040        assert!(nudge.contains("update_plan"), "{nudge}");
6041        assert!(nudge.contains("call the next tool"), "{nudge}");
6042        assert!(nudge.contains("concrete blocker"), "{nudge}");
6043
6044        let plan_update_nudge = pending_plan_completion_nudge(
6045            Some(&ledger as &dyn StepLedger),
6046            true,
6047            Some(
6048                "Configured workflow 'github_pr' is active. Workflow steps:\n- commit_step: Commit the verified step",
6049            ),
6050        )
6051        .expect("open plan produces a plan-update nudge");
6052        assert!(
6053            plan_update_nudge.contains("findings/next-steps summary"),
6054            "{plan_update_nudge}"
6055        );
6056        assert!(
6057            plan_update_nudge.contains("Call update_plan now"),
6058            "{plan_update_nudge}"
6059        );
6060        assert!(
6061            plan_update_nudge.contains("make the immediate blocker repair the active step"),
6062            "{plan_update_nudge}"
6063        );
6064        assert!(
6065            plan_update_nudge.contains("Do not repeat the findings summary"),
6066            "{plan_update_nudge}"
6067        );
6068        assert!(
6069            plan_update_nudge.contains("github_pr"),
6070            "{plan_update_nudge}"
6071        );
6072        assert!(
6073            plan_update_nudge.contains("commit_step"),
6074            "{plan_update_nudge}"
6075        );
6076
6077        ledger.restore(&PlanSnapshot {
6078            steps: vec![Step {
6079                description: "complete".to_string(),
6080                status: StepStatus::Done,
6081            }],
6082        });
6083        assert!(
6084            pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None).is_none()
6085        );
6086    }
6087
6088    #[test]
6089    fn workflow_classifier_text_keeps_recent_user_issue_context() {
6090        let messages = vec![
6091            serde_json::json!({
6092                "role": "user",
6093                "content": "Take a look at https://github.com/Gilamonster-Foundation/newt-agent/issues/548 and get me a PR."
6094            }),
6095            serde_json::json!({
6096                "role": "assistant",
6097                "content": "I will inspect the issue and repo state."
6098            }),
6099        ];
6100        let text = workflow_classifier_text(
6101            &messages,
6102            "Summary of Findings\n\nCurrent Status: the build is broken. Next Steps Required: update the plan.",
6103        );
6104        let hint = crate::WorkflowSteerer::builtin()
6105            .plan_update_hint(&text)
6106            .expect("GitHub issue context should select the PR workflow");
6107        assert!(hint.contains("github_pr"), "{hint}");
6108        assert!(hint.contains("read_issue"), "{hint}");
6109        assert!(hint.contains("open_pr"), "{hint}");
6110    }
6111}
6112
6113// ---------------------------------------------------------------------------
6114// Tool-call round cap + graceful cap-exit (issue: configurable max_tool_rounds)
6115// ---------------------------------------------------------------------------
6116//
6117// These tests exercise both agentic loops (`chat_complete` -> Ollama path and
6118// `openai_chat_complete`) against a wiremock backend. The mock returns tool
6119// calls while `tools` are present in the request and a real text answer once
6120// they are absent — letting us assert that:
6121//   (1) the loop honours the configured `max_tool_rounds` cap, and
6122//   (2) on hitting the cap newt issues ONE final tools-disabled completion and
6123//       returns its text (NOT the `(reached tool-call limit)` placeholder).
6124//
6125// (The companion test that recovers a hard context-window 400 via the
6126// `recover_cw_400` hook lives in newt-tui — it exercises the TUI-side probe
6127// cache persistence under a HOME env guard.)
6128#[cfg(test)]
6129mod tool_round_cap_tests {
6130    use super::*;
6131    use crate::caveats::Caveats;
6132    use crate::{BackendKind, MemMessage};
6133    use std::sync::atomic::{AtomicUsize, Ordering};
6134    use std::sync::Arc;
6135    use wiremock::matchers::{method, path};
6136    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
6137
6138    /// Was the `"tools"` key present on this request body?
6139    fn request_has_tools(req: &Request) -> bool {
6140        serde_json::from_slice::<serde_json::Value>(&req.body)
6141            .ok()
6142            .map(|v| v.get("tools").is_some())
6143            .unwrap_or(false)
6144    }
6145
6146    /// Ollama-shaped responder: returns a tool call whenever `tools` are
6147    /// offered, and a plain text answer once they are withheld. Counts the
6148    /// number of tool-offering requests it served.
6149    struct OllamaResponder {
6150        tool_rounds_served: Arc<AtomicUsize>,
6151        final_answer: String,
6152    }
6153
6154    impl Respond for OllamaResponder {
6155        fn respond(&self, req: &Request) -> ResponseTemplate {
6156            if request_has_tools(req) {
6157                self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6158                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6159                    "message": {
6160                        "content": "",
6161                        "tool_calls": [{
6162                            "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
6163                        }]
6164                    }
6165                }))
6166            } else {
6167                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6168                    "message": { "content": self.final_answer }
6169                }))
6170            }
6171        }
6172    }
6173
6174    /// OpenAI-shaped responder: same logic, OpenAI `choices[0].message` shape.
6175    struct OpenAiResponder {
6176        tool_rounds_served: Arc<AtomicUsize>,
6177        final_answer: String,
6178    }
6179
6180    impl Respond for OpenAiResponder {
6181        fn respond(&self, req: &Request) -> ResponseTemplate {
6182            if request_has_tools(req) {
6183                self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6184                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6185                    "choices": [{ "message": {
6186                        "content": null,
6187                        "tool_calls": [{
6188                            "id": "call_1",
6189                            "type": "function",
6190                            "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
6191                        }]
6192                    }}]
6193                }))
6194            } else {
6195                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6196                    "choices": [{ "message": { "content": self.final_answer } }]
6197                }))
6198            }
6199        }
6200    }
6201
6202    fn msgs() -> Vec<MemMessage> {
6203        vec![
6204            MemMessage::system("you are a test"),
6205            MemMessage::user("do the thing"),
6206        ]
6207    }
6208
6209    #[tokio::test]
6210    async fn ollama_loop_honors_configured_cap_and_returns_real_final_answer() {
6211        let server = MockServer::start().await;
6212        let served = Arc::new(AtomicUsize::new(0));
6213        Mock::given(method("POST"))
6214            .and(path("/api/chat"))
6215            .respond_with(OllamaResponder {
6216                tool_rounds_served: served.clone(),
6217                final_answer: "here is my partial summary".into(),
6218            })
6219            .mount(&server)
6220            .await;
6221
6222        let messages = msgs();
6223        let caveats = Caveats::top();
6224        let cap = 3;
6225        let (reply, streamed, _usage, _hallu) = chat_complete(
6226            ChatCtx {
6227                url: &server.uri(),
6228                model: "test-model",
6229                kind: BackendKind::Ollama,
6230                api_key: None,
6231                messages: &messages,
6232                task: "do the thing",
6233                workspace: ".",
6234                color: false,
6235                markdown: false,
6236                tool_offload: false,
6237                spill_store: None,
6238                compaction_store: None,
6239                scratchpad: false,
6240                scratchpad_store: None,
6241                code_search: None,
6242                experience_store: None,
6243                step_ledger: None,
6244                caveats: &caveats,
6245                max_tool_rounds: cap,
6246                workflow_grace_rounds: 0,
6247                tool_output_lines: 20,
6248                debug: false,
6249                trace: false,
6250                num_ctx: None,
6251                connect_timeout_secs: 5,
6252                inference_timeout_secs: 120,
6253                mid_loop_trim_threshold: 40,
6254                mid_loop_trim_tokens: None,
6255                max_ok_input: None,
6256                build_check_cmd: None,
6257                safe_context: None,
6258                recover_cw_400: None,
6259                note_sink: None,
6260                note_nudge: None,
6261                recall_source: None,
6262                memory_source: None,
6263                summarizer: None,
6264                compress_state: None,
6265                tool_events: None,
6266                phantom_reaches: None,
6267                permission_gate: None,
6268                on_round_usage: None,
6269                estimate_ratio: None,
6270                estimation: crate::tokens::TokenEstimation::default(),
6271                summary_input_cap_floor_chars: 8_192,
6272                exec_floor: None,
6273                write_ledger: None,
6274                cancel: None,
6275                git_tool: None,
6276                crew_runner: None,
6277            },
6278            &mut NoMcp,
6279        )
6280        .await
6281        .expect("chat_complete should succeed");
6282
6283        // The cap was honoured: exactly `cap` tool-offering rounds were served.
6284        assert_eq!(served.load(Ordering::SeqCst), cap);
6285        // The cap-exit issued a final tools-disabled completion and returned
6286        // its text — NOT the dead placeholder.
6287        assert_eq!(reply, "here is my partial summary");
6288        assert_ne!(reply, "(reached tool-call limit)");
6289        assert!(!streamed);
6290    }
6291
6292    #[tokio::test]
6293    async fn ollama_cap_exit_rejects_action_intent_summary() {
6294        let server = MockServer::start().await;
6295        Mock::given(method("POST"))
6296            .and(path("/api/chat"))
6297            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6298                "message": {
6299                    "content": "I have two issues: duplicate topic_has_rollups and a stray brace. Let me fix both — read around 490 to see what needs removing, then verify with a build check."
6300                }
6301            })))
6302            .mount(&server)
6303            .await;
6304
6305        let client = reqwest::Client::new();
6306        let chat_url = format!("{}/api/chat", server.uri());
6307        let (reply, streamed, _usage) = final_summary_ollama(
6308            &client,
6309            &chat_url,
6310            "test-model",
6311            Vec::new(),
6312            CapExit {
6313                max_tool_rounds: 25,
6314                accumulated: None,
6315                wasted_calls: 0,
6316                progress: Some("<plan>1. [ ] fix duplicate helper definitions</plan>".to_string()),
6317                observed: Vec::new(),
6318            },
6319        )
6320        .await
6321        .expect("final summary helper should return a fallback");
6322
6323        assert!(!streamed);
6324        assert!(reply.contains("tool-call limit of 25"), "{reply}");
6325        assert!(reply.contains("described future tool actions"), "{reply}");
6326        assert!(reply.contains("preserved the verified progress"), "{reply}");
6327        assert!(
6328            !reply.contains("Let me fix both"),
6329            "must not accept action-intent cap summary: {reply}"
6330        );
6331        assert!(
6332            !reply.contains("final summarization request also failed"),
6333            "{reply}"
6334        );
6335        assert!(
6336            reply.contains("Progress captured at the tool-call limit"),
6337            "{reply}"
6338        );
6339    }
6340
6341    /// UAT (Step 27.3 + 27.5, simulated integration): a thrash run — a DISTINCT
6342    /// failing tool call every round (so the failed-call count climbs to the
6343    /// cap) AND a final summary that also errors. The cap-exit must be HONEST:
6344    /// name the tooling problem, never advise "raise max_tool_rounds".
6345    struct ThrashResponder {
6346        round: AtomicUsize,
6347    }
6348
6349    impl Respond for ThrashResponder {
6350        fn respond(&self, req: &Request) -> ResponseTemplate {
6351            if request_has_tools(req) {
6352                let n = self.round.fetch_add(1, Ordering::SeqCst);
6353                // A distinct unknown tool each round → each fails and is NOT a
6354                // repeat, so the guard records every one (wasted_calls climbs to
6355                // the cap, which is what flips the cap-exit to honest advice).
6356                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6357                    "message": {
6358                        "content": "",
6359                        "tool_calls": [{
6360                            "function": { "name": format!("bogus_tool_{n}"), "arguments": {} }
6361                        }]
6362                    }
6363                }))
6364            } else {
6365                // The final tools-disabled summary request ALSO fails (500),
6366                // forcing the cap_exit_fallback path.
6367                ResponseTemplate::new(500).set_body_string("model exploded")
6368            }
6369        }
6370    }
6371
6372    #[tokio::test]
6373    async fn uat_thrash_run_gets_honest_cap_exit_not_raise_the_limit() {
6374        let server = MockServer::start().await;
6375        Mock::given(method("POST"))
6376            .and(path("/api/chat"))
6377            .respond_with(ThrashResponder {
6378                round: AtomicUsize::new(0),
6379            })
6380            .mount(&server)
6381            .await;
6382
6383        let messages = msgs();
6384        let caveats = Caveats::top();
6385        let cap = 3;
6386        let (reply, _streamed, _usage, hallu) = chat_complete(
6387            ChatCtx {
6388                url: &server.uri(),
6389                model: "test-model",
6390                kind: BackendKind::Ollama,
6391                api_key: None,
6392                messages: &messages,
6393                task: "do the thing",
6394                workspace: ".",
6395                color: false,
6396                markdown: false,
6397                tool_offload: false,
6398                spill_store: None,
6399                compaction_store: None,
6400                scratchpad: false,
6401                scratchpad_store: None,
6402                code_search: None,
6403                experience_store: None,
6404                step_ledger: None,
6405                caveats: &caveats,
6406                max_tool_rounds: cap,
6407                workflow_grace_rounds: 0,
6408                tool_output_lines: 20,
6409                debug: false,
6410                trace: false,
6411                num_ctx: None,
6412                connect_timeout_secs: 5,
6413                inference_timeout_secs: 120,
6414                mid_loop_trim_threshold: 40,
6415                mid_loop_trim_tokens: None,
6416                max_ok_input: None,
6417                build_check_cmd: None,
6418                safe_context: None,
6419                recover_cw_400: None,
6420                note_sink: None,
6421                note_nudge: None,
6422                recall_source: None,
6423                memory_source: None,
6424                summarizer: None,
6425                compress_state: None,
6426                tool_events: None,
6427                phantom_reaches: None,
6428                permission_gate: None,
6429                on_round_usage: None,
6430                estimate_ratio: None,
6431                estimation: crate::tokens::TokenEstimation::default(),
6432                summary_input_cap_floor_chars: 8_192,
6433                exec_floor: None,
6434                write_ledger: None,
6435                cancel: None,
6436                git_tool: None,
6437                crew_runner: None,
6438            },
6439            &mut NoMcp,
6440        )
6441        .await
6442        .expect("chat_complete should succeed even when the summary fails");
6443
6444        // Every round emitted a (distinct) bogus call → counted as a hallucination.
6445        assert_eq!(hallu, cap as u32, "each round hallucinated a tool");
6446        // Step 27.5: the cap-exit is HONEST — a tooling problem, NOT "raise the cap".
6447        assert!(
6448            reply.contains("tool calls that failed"),
6449            "honest advice expected, got: {reply}"
6450        );
6451        assert!(
6452            !reply.contains("raise [tui].max_tool_rounds"),
6453            "must not blame the round cap on a thrash run: {reply}"
6454        );
6455    }
6456
6457    #[tokio::test]
6458    async fn a_set_cancel_flag_abandons_the_turn_before_any_network_call() {
6459        // The interrupt checkpoint at the round-loop top runs before the first
6460        // request, so a pre-tripped flag returns instantly — the bogus URL
6461        // (a closed port) is never contacted. If the checkpoint regressed,
6462        // the dispatch would try to connect and this would not return empty.
6463        let messages = msgs();
6464        let caveats = Caveats::top();
6465        let flag = std::sync::atomic::AtomicBool::new(true);
6466        let (reply, streamed, usage, hallu) = chat_complete(
6467            ChatCtx {
6468                url: "http://127.0.0.1:1",
6469                model: "test-model",
6470                kind: BackendKind::Ollama,
6471                api_key: None,
6472                messages: &messages,
6473                task: "do the thing",
6474                workspace: ".",
6475                color: false,
6476                markdown: false,
6477                tool_offload: false,
6478                spill_store: None,
6479                compaction_store: None,
6480                scratchpad: false,
6481                scratchpad_store: None,
6482                code_search: None,
6483                experience_store: None,
6484                step_ledger: None,
6485                caveats: &caveats,
6486                max_tool_rounds: 5,
6487                workflow_grace_rounds: 0,
6488                tool_output_lines: 20,
6489                debug: false,
6490                trace: false,
6491                num_ctx: None,
6492                connect_timeout_secs: 5,
6493                inference_timeout_secs: 120,
6494                mid_loop_trim_threshold: 40,
6495                mid_loop_trim_tokens: None,
6496                max_ok_input: None,
6497                build_check_cmd: None,
6498                safe_context: None,
6499                recover_cw_400: None,
6500                note_sink: None,
6501                note_nudge: None,
6502                recall_source: None,
6503                memory_source: None,
6504                summarizer: None,
6505                compress_state: None,
6506                tool_events: None,
6507                phantom_reaches: None,
6508                permission_gate: None,
6509                on_round_usage: None,
6510                estimate_ratio: None,
6511                estimation: crate::tokens::TokenEstimation::default(),
6512                summary_input_cap_floor_chars: 8_192,
6513                exec_floor: None,
6514                write_ledger: None,
6515                cancel: Some(&flag),
6516                git_tool: None,
6517                crew_runner: None,
6518            },
6519            &mut NoMcp,
6520        )
6521        .await
6522        .expect("an interrupted turn still returns Ok, just empty");
6523        assert!(reply.is_empty(), "interrupted before any model output");
6524        assert!(!streamed);
6525        assert!(usage.is_none());
6526        assert_eq!(hallu, 0);
6527    }
6528
6529    #[test]
6530    fn responses_input_splits_system_to_instructions_and_passes_typed_items() {
6531        let msgs = vec![
6532            serde_json::json!({"role": "system", "content": "be terse"}),
6533            serde_json::json!({"role": "user", "content": "hi"}),
6534            serde_json::json!({"role": "assistant", "content": "hello"}),
6535            // an already-typed Responses item passes through untouched
6536            serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "ok"}),
6537        ];
6538        let (instructions, input) = build_responses_input(&msgs);
6539        assert_eq!(instructions.as_deref(), Some("be terse"));
6540        assert_eq!(input.len(), 3);
6541        assert_eq!(input[0]["role"], "user");
6542        assert_eq!(input[0]["content"], "hi");
6543        assert_eq!(input[2]["type"], "function_call_output");
6544    }
6545
6546    #[test]
6547    fn tools_flatten_to_responses_shape() {
6548        let chat = serde_json::json!([{
6549            "type": "function",
6550            "function": {
6551                "name": "git",
6552                "description": "run git",
6553                "parameters": {"type": "object"}
6554            }
6555        }]);
6556        let out = tools_to_responses(&chat);
6557        assert_eq!(out.len(), 1);
6558        assert_eq!(out[0]["type"], "function");
6559        assert_eq!(
6560            out[0]["name"], "git",
6561            "name hoisted out of the function wrapper"
6562        );
6563        assert_eq!(out[0]["description"], "run git");
6564        assert!(out[0]["function"].is_null(), "no nested function wrapper");
6565    }
6566
6567    #[test]
6568    fn parse_responses_output_extracts_text_calls_and_usage() {
6569        let json = serde_json::json!({
6570            "output": [
6571                {"type": "reasoning", "summary": "…"},
6572                {"type": "message", "role": "assistant",
6573                 "content": [{"type": "output_text", "text": "the answer"}]},
6574                {"type": "function_call", "call_id": "call_1", "name": "git",
6575                 "arguments": "{\"op\":\"status\"}"}
6576            ],
6577            "usage": {"input_tokens": 100, "output_tokens": 20}
6578        });
6579        let (text, calls) = parse_responses_output(&json);
6580        assert_eq!(text, "the answer");
6581        assert_eq!(calls.len(), 1);
6582        assert_eq!(calls[0]["call_id"], "call_1");
6583        let usage = responses_usage(&json["usage"]).unwrap();
6584        assert_eq!(usage.input_tokens, 100);
6585        assert_eq!(usage.output_tokens, 20);
6586    }
6587
6588    #[tokio::test]
6589    async fn responses_loop_returns_message_text_from_v1_responses() {
6590        let server = MockServer::start().await;
6591        Mock::given(method("POST"))
6592            .and(path("/v1/responses"))
6593            .respond_with(
6594                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6595                    "output": [{
6596                        "type": "message", "role": "assistant",
6597                        "content": [{"type": "output_text", "text": "hello from responses"}]
6598                    }],
6599                    "usage": {"input_tokens": 12, "output_tokens": 4}
6600                })),
6601            )
6602            .mount(&server)
6603            .await;
6604
6605        let messages = msgs();
6606        let caveats = Caveats::top();
6607        let (reply, streamed, usage, _hallu) = openai_responses_complete(
6608            ChatCtx {
6609                url: &server.uri(),
6610                model: "gpt-5-codex",
6611                kind: BackendKind::Openai,
6612                api_key: Some("sk-test"),
6613                messages: &messages,
6614                task: "do the thing",
6615                workspace: ".",
6616                color: false,
6617                markdown: false,
6618                tool_offload: false,
6619                spill_store: None,
6620                compaction_store: None,
6621                scratchpad: false,
6622                scratchpad_store: None,
6623                code_search: None,
6624                experience_store: None,
6625                step_ledger: None,
6626                caveats: &caveats,
6627                max_tool_rounds: 5,
6628                workflow_grace_rounds: 0,
6629                tool_output_lines: 20,
6630                debug: false,
6631                trace: false,
6632                num_ctx: None,
6633                connect_timeout_secs: 5,
6634                inference_timeout_secs: 120,
6635                mid_loop_trim_threshold: 40,
6636                mid_loop_trim_tokens: None,
6637                max_ok_input: None,
6638                build_check_cmd: None,
6639                safe_context: None,
6640                recover_cw_400: None,
6641                note_sink: None,
6642                note_nudge: None,
6643                recall_source: None,
6644                memory_source: None,
6645                summarizer: None,
6646                compress_state: None,
6647                tool_events: None,
6648                phantom_reaches: None,
6649                permission_gate: None,
6650                on_round_usage: None,
6651                estimate_ratio: None,
6652                estimation: crate::tokens::TokenEstimation::default(),
6653                summary_input_cap_floor_chars: 8_192,
6654                exec_floor: None,
6655                write_ledger: None,
6656                cancel: None,
6657                git_tool: None,
6658                crew_runner: None,
6659            },
6660            &mut NoMcp,
6661        )
6662        .await
6663        .expect("responses loop returns the message text");
6664        assert_eq!(reply, "hello from responses");
6665        assert!(!streamed);
6666        assert_eq!(usage.map(|u| u.input_tokens), Some(12));
6667    }
6668
6669    #[tokio::test]
6670    async fn openai_loop_honors_configured_cap_and_returns_real_final_answer() {
6671        let server = MockServer::start().await;
6672        let served = Arc::new(AtomicUsize::new(0));
6673        Mock::given(method("POST"))
6674            .and(path("/v1/chat/completions"))
6675            .respond_with(OpenAiResponder {
6676                tool_rounds_served: served.clone(),
6677                final_answer: "openai partial answer".into(),
6678            })
6679            .mount(&server)
6680            .await;
6681
6682        let messages = msgs();
6683        let caveats = Caveats::top();
6684        let cap = 2;
6685        let (reply, streamed, _usage, _hallu) = openai_chat_complete(
6686            ChatCtx {
6687                url: &server.uri(),
6688                model: "test-model",
6689                kind: BackendKind::Openai,
6690                api_key: Some("sk-test"),
6691                messages: &messages,
6692                task: "do the thing",
6693                workspace: ".",
6694                color: false,
6695                markdown: false,
6696                tool_offload: false,
6697                spill_store: None,
6698                compaction_store: None,
6699                scratchpad: false,
6700                scratchpad_store: None,
6701                code_search: None,
6702                experience_store: None,
6703                step_ledger: None,
6704                caveats: &caveats,
6705                max_tool_rounds: cap,
6706                workflow_grace_rounds: 0,
6707                tool_output_lines: 20,
6708                debug: false,
6709                trace: false,
6710                num_ctx: None,
6711                connect_timeout_secs: 5,
6712                inference_timeout_secs: 120,
6713                mid_loop_trim_threshold: 40,
6714                mid_loop_trim_tokens: None,
6715                max_ok_input: None,
6716                build_check_cmd: None,
6717                safe_context: None,
6718                recover_cw_400: None,
6719                note_sink: None,
6720                note_nudge: None,
6721                recall_source: None,
6722                memory_source: None,
6723                summarizer: None,
6724                compress_state: None,
6725                tool_events: None,
6726                phantom_reaches: None,
6727                permission_gate: None,
6728                on_round_usage: None,
6729                estimate_ratio: None,
6730                estimation: crate::tokens::TokenEstimation::default(),
6731                summary_input_cap_floor_chars: 8_192,
6732                exec_floor: None,
6733                write_ledger: None,
6734                cancel: None,
6735                git_tool: None,
6736                crew_runner: None,
6737            },
6738            &mut NoMcp,
6739        )
6740        .await
6741        .expect("openai_chat_complete should succeed");
6742
6743        assert_eq!(served.load(Ordering::SeqCst), cap);
6744        assert_eq!(reply, "openai partial answer");
6745        assert_ne!(reply, "(reached tool-call limit)");
6746        assert!(!streamed);
6747    }
6748
6749    /// 17.6: with a recorder lent in `ChatCtx.tool_events`, the Ollama loop
6750    /// records one event per executed tool call — name as invoked, digested
6751    /// args (keys + hash, never raw values), best-effort outcome, duration
6752    /// claim. Without a recorder (every other test here) nothing changes.
6753    #[tokio::test]
6754    async fn ollama_loop_records_tool_events_with_digested_args() {
6755        let server = MockServer::start().await;
6756        struct TwoToolResponder;
6757        impl Respond for TwoToolResponder {
6758            fn respond(&self, req: &Request) -> ResponseTemplate {
6759                if request_has_tools(req) {
6760                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
6761                        "message": { "content": "", "tool_calls": [
6762                            { "function": { "name": "list_dir",
6763                                            "arguments": {"path": "."} } },
6764                            { "function": { "name": "definitely_not_a_real_tool",
6765                                            "arguments": {"token": "tippy-top-secret"} } }
6766                        ]}
6767                    }))
6768                } else {
6769                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
6770                        "message": { "content": "done" }
6771                    }))
6772                }
6773            }
6774        }
6775        Mock::given(method("POST"))
6776            .and(path("/api/chat"))
6777            .respond_with(TwoToolResponder)
6778            .mount(&server)
6779            .await;
6780
6781        let ws = tempfile::TempDir::new().unwrap();
6782        let workspace = ws.path().to_string_lossy().into_owned();
6783        let messages = msgs();
6784        let caveats = Caveats::top();
6785        let mut events: Vec<crate::ToolEvent> = Vec::new();
6786        chat_complete(
6787            ChatCtx {
6788                url: &server.uri(),
6789                model: "test-model",
6790                kind: BackendKind::Ollama,
6791                api_key: None,
6792                messages: &messages,
6793                task: "do the thing",
6794                workspace: &workspace,
6795                color: false,
6796                markdown: false,
6797                tool_offload: false,
6798                spill_store: None,
6799                compaction_store: None,
6800                scratchpad: false,
6801                scratchpad_store: None,
6802                code_search: None,
6803                experience_store: None,
6804                step_ledger: None,
6805                caveats: &caveats,
6806                max_tool_rounds: 1,
6807                workflow_grace_rounds: 0,
6808                tool_output_lines: 20,
6809                debug: false,
6810                trace: false,
6811                num_ctx: None,
6812                connect_timeout_secs: 5,
6813                inference_timeout_secs: 120,
6814                mid_loop_trim_threshold: 40,
6815                mid_loop_trim_tokens: None,
6816                max_ok_input: None,
6817                build_check_cmd: None,
6818                safe_context: None,
6819                recover_cw_400: None,
6820                note_sink: None,
6821                note_nudge: None,
6822                recall_source: None,
6823                memory_source: None,
6824                summarizer: None,
6825                compress_state: None,
6826                tool_events: Some(&mut events),
6827                phantom_reaches: None,
6828                permission_gate: None,
6829                on_round_usage: None,
6830                estimate_ratio: None,
6831                estimation: crate::tokens::TokenEstimation::default(),
6832                summary_input_cap_floor_chars: 8_192,
6833                exec_floor: None,
6834                write_ledger: None,
6835                cancel: None,
6836                git_tool: None,
6837                crew_runner: None,
6838            },
6839            &mut NoMcp,
6840        )
6841        .await
6842        .expect("chat_complete should succeed");
6843
6844        assert_eq!(events.len(), 2, "one event per tool call: {events:?}");
6845        assert_eq!(events[0].tool, "list_dir");
6846        assert!(events[0].ok, "a real listing reads as success");
6847        assert!(events[0].args_digest.contains("path"));
6848        assert!(events[0].duration_ms.is_some());
6849        assert_eq!(events[1].tool, "definitely_not_a_real_tool");
6850        assert!(!events[1].ok, "an unknown tool reads as failure");
6851        // Args are digested, never recorded raw.
6852        assert!(events[1].args_digest.contains("token"));
6853        assert!(
6854            !events[1].args_digest.contains("tippy-top-secret"),
6855            "raw arg value leaked: {}",
6856            events[1].args_digest
6857        );
6858    }
6859
6860    /// 17.6: the OpenAI loop records the same per-call events (its tool
6861    /// arguments arrive as a JSON *string* — the digest must match the
6862    /// parsed-args digest the Ollama path produces for identical args).
6863    #[tokio::test]
6864    async fn openai_loop_records_tool_events_with_digested_args() {
6865        let server = MockServer::start().await;
6866        struct OneToolResponder;
6867        impl Respond for OneToolResponder {
6868            fn respond(&self, req: &Request) -> ResponseTemplate {
6869                if request_has_tools(req) {
6870                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
6871                        "choices": [{ "message": {
6872                            "content": null,
6873                            "tool_calls": [{
6874                                "id": "call_1",
6875                                "type": "function",
6876                                "function": { "name": "list_dir",
6877                                              "arguments": "{\"path\": \".\"}" }
6878                            }]
6879                        }}]
6880                    }))
6881                } else {
6882                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
6883                        "choices": [{ "message": { "content": "done" } }]
6884                    }))
6885                }
6886            }
6887        }
6888        Mock::given(method("POST"))
6889            .and(path("/v1/chat/completions"))
6890            .respond_with(OneToolResponder)
6891            .mount(&server)
6892            .await;
6893
6894        let ws = tempfile::TempDir::new().unwrap();
6895        let workspace = ws.path().to_string_lossy().into_owned();
6896        let messages = msgs();
6897        let caveats = Caveats::top();
6898        let mut events: Vec<crate::ToolEvent> = Vec::new();
6899        openai_chat_complete(
6900            ChatCtx {
6901                url: &server.uri(),
6902                model: "test-model",
6903                kind: BackendKind::Openai,
6904                api_key: Some("sk-test"),
6905                messages: &messages,
6906                task: "do the thing",
6907                workspace: &workspace,
6908                color: false,
6909                markdown: false,
6910                tool_offload: false,
6911                spill_store: None,
6912                compaction_store: None,
6913                scratchpad: false,
6914                scratchpad_store: None,
6915                code_search: None,
6916                experience_store: None,
6917                step_ledger: None,
6918                caveats: &caveats,
6919                max_tool_rounds: 1,
6920                workflow_grace_rounds: 0,
6921                tool_output_lines: 20,
6922                debug: false,
6923                trace: false,
6924                num_ctx: None,
6925                connect_timeout_secs: 5,
6926                inference_timeout_secs: 120,
6927                mid_loop_trim_threshold: 40,
6928                mid_loop_trim_tokens: None,
6929                max_ok_input: None,
6930                build_check_cmd: None,
6931                safe_context: None,
6932                recover_cw_400: None,
6933                note_sink: None,
6934                note_nudge: None,
6935                recall_source: None,
6936                memory_source: None,
6937                summarizer: None,
6938                compress_state: None,
6939                tool_events: Some(&mut events),
6940                phantom_reaches: None,
6941                permission_gate: None,
6942                on_round_usage: None,
6943                estimate_ratio: None,
6944                estimation: crate::tokens::TokenEstimation::default(),
6945                summary_input_cap_floor_chars: 8_192,
6946                exec_floor: None,
6947                write_ledger: None,
6948                cancel: None,
6949                git_tool: None,
6950                crew_runner: None,
6951            },
6952            &mut NoMcp,
6953        )
6954        .await
6955        .expect("openai_chat_complete should succeed");
6956
6957        assert_eq!(events.len(), 1, "one event per tool call: {events:?}");
6958        assert_eq!(events[0].tool, "list_dir");
6959        assert!(events[0].ok);
6960        assert_eq!(
6961            events[0].args_digest,
6962            crate::ToolEvent::from_call("x", &serde_json::json!({"path": "."}), true, None)
6963                .args_digest,
6964            "string-encoded args must digest like parsed args"
6965        );
6966    }
6967
6968    #[tokio::test]
6969    async fn cap_exit_fallback_when_final_summary_errors() {
6970        // No mock for the tools-disabled request would still 404 via the
6971        // tool-offering mock only matching when... actually both match the same
6972        // path, so instead we mount a server that always 500s the *second*
6973        // shape. Simpler: a server that returns tool calls for tools-present
6974        // and a 500 for tools-absent, forcing the fallback branch.
6975        let server = MockServer::start().await;
6976        let served = Arc::new(AtomicUsize::new(0));
6977        struct ErrOnFinal {
6978            served: Arc<AtomicUsize>,
6979        }
6980        impl Respond for ErrOnFinal {
6981            fn respond(&self, req: &Request) -> ResponseTemplate {
6982                if request_has_tools(req) {
6983                    self.served.fetch_add(1, Ordering::SeqCst);
6984                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
6985                        "message": { "content": "", "tool_calls": [{
6986                            "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
6987                        }]}
6988                    }))
6989                } else {
6990                    ResponseTemplate::new(500).set_body_string("boom")
6991                }
6992            }
6993        }
6994        Mock::given(method("POST"))
6995            .and(path("/api/chat"))
6996            .respond_with(ErrOnFinal {
6997                served: served.clone(),
6998            })
6999            .mount(&server)
7000            .await;
7001
7002        let messages = msgs();
7003        let caveats = Caveats::top();
7004        let (reply, _streamed, _usage, _hallu) = chat_complete(
7005            ChatCtx {
7006                url: &server.uri(),
7007                model: "test-model",
7008                kind: BackendKind::Ollama,
7009                api_key: None,
7010                messages: &messages,
7011                task: "do the thing",
7012                workspace: ".",
7013                color: false,
7014                markdown: false,
7015                tool_offload: false,
7016                spill_store: None,
7017                compaction_store: None,
7018                scratchpad: false,
7019                scratchpad_store: None,
7020                code_search: None,
7021                experience_store: None,
7022                step_ledger: None,
7023                caveats: &caveats,
7024                max_tool_rounds: 2,
7025                workflow_grace_rounds: 0,
7026                tool_output_lines: 20,
7027                debug: false,
7028                trace: false,
7029                num_ctx: None,
7030                connect_timeout_secs: 5,
7031                inference_timeout_secs: 120,
7032                mid_loop_trim_threshold: 40,
7033                mid_loop_trim_tokens: None,
7034                max_ok_input: None,
7035                build_check_cmd: None,
7036                safe_context: None,
7037                recover_cw_400: None,
7038                note_sink: None,
7039                note_nudge: None,
7040                recall_source: None,
7041                memory_source: None,
7042                summarizer: None,
7043                compress_state: None,
7044                tool_events: None,
7045                phantom_reaches: None,
7046                permission_gate: None,
7047                on_round_usage: None,
7048                estimate_ratio: None,
7049                estimation: crate::tokens::TokenEstimation::default(),
7050                summary_input_cap_floor_chars: 8_192,
7051                exec_floor: None,
7052                write_ledger: None,
7053                cancel: None,
7054                git_tool: None,
7055                crew_runner: None,
7056            },
7057            &mut NoMcp,
7058        )
7059        .await
7060        .expect("chat_complete should succeed even when final summary errors");
7061
7062        // Fallback names the limit + the knob — strictly better than the bare
7063        // placeholder.
7064        assert!(reply.contains("tool-call limit"));
7065        assert!(reply.contains("max_tool_rounds"));
7066    }
7067
7068    /// `run_command` called with a tool name as the first word must return a
7069    /// corrective error message, not shell it through agent-bridle.
7070    #[tokio::test]
7071    async fn run_command_refuses_tool_name_as_shell_command() {
7072        let ws = tempfile::TempDir::new().unwrap();
7073        let caveats = Caveats::top();
7074        for tool in [
7075            "list_dir",
7076            "read_file",
7077            "write_file",
7078            "use_skill",
7079            "web_fetch",
7080        ] {
7081            let args = serde_json::json!({ "command": format!("{tool} some/path") });
7082            let out = execute_tool(
7083                "run_command",
7084                &args,
7085                &ws.path().to_string_lossy(),
7086                false,
7087                20,
7088                &caveats,
7089                &mut NoMcp,
7090                None,
7091                None,
7092                None,
7093                None, // memory_source
7094                None,
7095                None,
7096                None, // git_tool
7097                None, // crew_runner
7098                None, // scratchpad_store
7099                None, // code_search
7100                None, // experience_store
7101                None, // step_ledger
7102            )
7103            .await;
7104            assert!(
7105                out.contains("is a tool, not a shell command"),
7106                "expected corrective message for '{tool}', got: {out}"
7107            );
7108        }
7109    }
7110
7111    /// When the final summary 500s, the accumulated usage from the tool rounds
7112    /// must still be returned (not None), so usage.jsonl is not blank.
7113    #[tokio::test]
7114    async fn accumulated_usage_survives_summary_failure() {
7115        let server = MockServer::start().await;
7116        let served = Arc::new(AtomicUsize::new(0));
7117
7118        struct UsageRoundsErrFinal {
7119            served: Arc<AtomicUsize>,
7120        }
7121        impl Respond for UsageRoundsErrFinal {
7122            fn respond(&self, req: &Request) -> ResponseTemplate {
7123                if request_has_tools(req) {
7124                    self.served.fetch_add(1, Ordering::SeqCst);
7125                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7126                        "message": { "content": "", "tool_calls": [{
7127                            "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
7128                        }]},
7129                        // Ollama reports per-round usage even in non-streaming mode.
7130                        "prompt_eval_count": 100,
7131                        "eval_count": 20,
7132                    }))
7133                } else {
7134                    ResponseTemplate::new(500).set_body_string("boom")
7135                }
7136            }
7137        }
7138
7139        Mock::given(method("POST"))
7140            .and(path("/api/chat"))
7141            .respond_with(UsageRoundsErrFinal {
7142                served: served.clone(),
7143            })
7144            .mount(&server)
7145            .await;
7146
7147        let messages = msgs();
7148        let caveats = Caveats::top();
7149        let cap = 2;
7150        let (reply, _streamed, usage, hallu) = chat_complete(
7151            ChatCtx {
7152                url: &server.uri(),
7153                model: "test-model",
7154                kind: BackendKind::Ollama,
7155                api_key: None,
7156                messages: &messages,
7157                task: "do the thing",
7158                workspace: ".",
7159                color: false,
7160                markdown: false,
7161                tool_offload: false,
7162                spill_store: None,
7163                compaction_store: None,
7164                scratchpad: false,
7165                scratchpad_store: None,
7166                code_search: None,
7167                experience_store: None,
7168                step_ledger: None,
7169                caveats: &caveats,
7170                max_tool_rounds: cap,
7171                workflow_grace_rounds: 0,
7172                tool_output_lines: 20,
7173                debug: false,
7174                trace: false,
7175                num_ctx: None,
7176                connect_timeout_secs: 5,
7177                inference_timeout_secs: 120,
7178                mid_loop_trim_threshold: 40,
7179                mid_loop_trim_tokens: None,
7180                max_ok_input: None,
7181                build_check_cmd: None,
7182                safe_context: None,
7183                recover_cw_400: None,
7184                note_sink: None,
7185                note_nudge: None,
7186                recall_source: None,
7187                memory_source: None,
7188                summarizer: None,
7189                compress_state: None,
7190                tool_events: None,
7191                phantom_reaches: None,
7192                permission_gate: None,
7193                on_round_usage: None,
7194                estimate_ratio: None,
7195                estimation: crate::tokens::TokenEstimation::default(),
7196                summary_input_cap_floor_chars: 8_192,
7197                exec_floor: None,
7198                write_ledger: None,
7199                cancel: None,
7200                git_tool: None,
7201                crew_runner: None,
7202            },
7203            &mut NoMcp,
7204        )
7205        .await
7206        .expect("chat_complete must succeed even when final summary errors");
7207
7208        // The fallback reply must contain accumulated token counts.
7209        assert!(reply.contains("tool-call limit"), "got: {reply}");
7210        assert!(
7211            reply.contains("in / ") && reply.contains("out tokens"),
7212            "fallback must include accumulated token counts, got: {reply}"
7213        );
7214
7215        // The usage returned must be non-None and reflect the rounds.
7216        let u = usage.expect("usage must be Some even when final summary fails");
7217        // SEMANTICS CHANGED in Step 18.1: each round's 100-token prompt
7218        // contained the same history, so the turn input is the largest single
7219        // prompt (100), not the 200 sum that double-counted it.
7220        assert_eq!(
7221            u.input_tokens, 100,
7222            "largest single prompt across 2 rounds, not the sum"
7223        );
7224        assert_eq!(
7225            u.output_tokens, 40,
7226            "2 rounds × 20 output tokens each = 40 total"
7227        );
7228
7229        // Unknown tool calls during cap rounds counted as hallucinations.
7230        assert_eq!(
7231            hallu, cap as u32,
7232            "each round had one hallucinated tool call"
7233        );
7234    }
7235
7236    // -----------------------------------------------------------------------
7237    // Read-only nudge injection test
7238    //
7239    // Scenario: model keeps calling list_dir (read-only) for 3 rounds.
7240    // On round 4 the harness injects the nudge.  The responder detects the
7241    // nudge text in the message list and returns a final text answer instead
7242    // of another tool call, proving the nudge reached the model.
7243    // -----------------------------------------------------------------------
7244
7245    struct ReadOnlyNudgeResponder {
7246        /// Flipped to true the first time the responder sees the nudge text.
7247        nudge_seen: Arc<std::sync::atomic::AtomicBool>,
7248    }
7249
7250    impl Respond for ReadOnlyNudgeResponder {
7251        fn respond(&self, req: &Request) -> ResponseTemplate {
7252            let body = serde_json::from_slice::<serde_json::Value>(&req.body).unwrap_or_default();
7253            let has_nudge = body["messages"]
7254                .as_array()
7255                .map(|msgs| {
7256                    msgs.iter().any(|m| {
7257                        m["content"]
7258                            .as_str()
7259                            .map(|c| c.contains("read-only rounds so far"))
7260                            .unwrap_or(false)
7261                    })
7262                })
7263                .unwrap_or(false);
7264
7265            if has_nudge {
7266                self.nudge_seen
7267                    .store(true, std::sync::atomic::Ordering::SeqCst);
7268                // Return a plain text answer — no more tool calls.
7269                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7270                    "message": { "content": "nudge received, writing file now" }
7271                }))
7272            } else if request_has_tools(req) {
7273                // Keep returning list_dir calls until the nudge arrives.
7274                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7275                    "message": {
7276                        "content": "",
7277                        "tool_calls": [{ "function": {
7278                            "name": "list_dir",
7279                            "arguments": { "path": "." }
7280                        }}]
7281                    }
7282                }))
7283            } else {
7284                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7285                    "message": { "content": "final summary" }
7286                }))
7287            }
7288        }
7289    }
7290
7291    #[tokio::test]
7292    async fn read_only_nudge_injected_after_three_rounds() {
7293        let server = MockServer::start().await;
7294        let nudge_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
7295
7296        Mock::given(method("POST"))
7297            .and(path("/api/chat"))
7298            .respond_with(ReadOnlyNudgeResponder {
7299                nudge_seen: nudge_seen.clone(),
7300            })
7301            .mount(&server)
7302            .await;
7303
7304        let messages = msgs();
7305        let caveats = Caveats::top();
7306        let (reply, _streamed, _usage, _hallu) = chat_complete(
7307            ChatCtx {
7308                url: &server.uri(),
7309                model: "test-model",
7310                kind: BackendKind::Ollama,
7311                api_key: None,
7312                messages: &messages,
7313                task: "list all files",
7314                workspace: ".",
7315                color: false,
7316                markdown: false,
7317                tool_offload: false,
7318                spill_store: None,
7319                compaction_store: None,
7320                scratchpad: false,
7321                scratchpad_store: None,
7322                code_search: None,
7323                experience_store: None,
7324                step_ledger: None,
7325                caveats: &caveats,
7326                max_tool_rounds: 10,
7327                workflow_grace_rounds: 0,
7328                tool_output_lines: 5,
7329                debug: false,
7330                trace: false,
7331                num_ctx: None,
7332                connect_timeout_secs: 5,
7333                inference_timeout_secs: 30,
7334                mid_loop_trim_threshold: 40,
7335                mid_loop_trim_tokens: None,
7336                max_ok_input: None,
7337                build_check_cmd: None,
7338                safe_context: None,
7339                recover_cw_400: None,
7340                note_sink: None,
7341                note_nudge: None,
7342                recall_source: None,
7343                memory_source: None,
7344                summarizer: None,
7345                compress_state: None,
7346                tool_events: None,
7347                phantom_reaches: None,
7348                permission_gate: None,
7349                on_round_usage: None,
7350                estimate_ratio: None,
7351                estimation: crate::tokens::TokenEstimation::default(),
7352                summary_input_cap_floor_chars: 8_192,
7353                exec_floor: None,
7354                write_ledger: None,
7355                cancel: None,
7356                git_tool: None,
7357                crew_runner: None,
7358            },
7359            &mut NoMcp,
7360        )
7361        .await
7362        .expect("chat_complete should succeed");
7363
7364        assert!(
7365            nudge_seen.load(std::sync::atomic::Ordering::SeqCst),
7366            "nudge was never injected after 3 consecutive read-only rounds"
7367        );
7368        assert_eq!(
7369            reply, "nudge received, writing file now",
7370            "model should have responded to the nudge with a final answer"
7371        );
7372    }
7373}
7374
7375// ---------------------------------------------------------------------------
7376// HTTP-loop tests — streaming, overflow retry, mid-loop trim, and final
7377// summary, all against wiremock backends.
7378// ---------------------------------------------------------------------------
7379
7380#[cfg(test)]
7381mod http_loop_tests {
7382    use super::*;
7383    use crate::caveats::Caveats;
7384    use crate::{BackendKind, MemMessage};
7385    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7386    use std::sync::Arc;
7387    use wiremock::matchers::{header, method, path};
7388    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
7389
7390    fn msgs() -> Vec<MemMessage> {
7391        vec![
7392            MemMessage::system("you are a test"),
7393            MemMessage::user("do the thing"),
7394        ]
7395    }
7396
7397    fn ctx<'a>(
7398        server_uri: &'a str,
7399        messages: &'a [MemMessage],
7400        caveats: &'a Caveats,
7401    ) -> ChatCtx<'a> {
7402        ChatCtx {
7403            url: server_uri,
7404            model: "test-model",
7405            kind: BackendKind::Ollama,
7406            api_key: None,
7407            messages,
7408            task: "do the thing",
7409            workspace: ".",
7410            color: false,
7411            markdown: false,
7412            tool_offload: false,
7413            spill_store: None,
7414            compaction_store: None,
7415            scratchpad: false,
7416            scratchpad_store: None,
7417            code_search: None,
7418            experience_store: None,
7419            step_ledger: None,
7420            caveats,
7421            max_tool_rounds: 8,
7422            workflow_grace_rounds: 0,
7423            tool_output_lines: 20,
7424            debug: false,
7425            trace: false,
7426            num_ctx: None,
7427            connect_timeout_secs: 5,
7428            inference_timeout_secs: 30,
7429            mid_loop_trim_threshold: 40,
7430            mid_loop_trim_tokens: None,
7431            max_ok_input: None,
7432            build_check_cmd: None,
7433            safe_context: None,
7434            recover_cw_400: None,
7435            note_sink: None,
7436            note_nudge: None,
7437            recall_source: None,
7438            memory_source: None,
7439            summarizer: None,
7440            compress_state: None,
7441            tool_events: None,
7442            phantom_reaches: None,
7443            permission_gate: None,
7444            on_round_usage: None,
7445            estimate_ratio: None,
7446            estimation: crate::tokens::TokenEstimation::default(),
7447            summary_input_cap_floor_chars: 8_192,
7448            exec_floor: None,
7449            write_ledger: None,
7450            cancel: None,
7451            git_tool: None,
7452            crew_runner: None,
7453        }
7454    }
7455
7456    fn body_json(req: &Request) -> serde_json::Value {
7457        serde_json::from_slice(&req.body).unwrap_or_default()
7458    }
7459
7460    fn is_stream(req: &Request) -> bool {
7461        body_json(req)["stream"].as_bool().unwrap_or(false)
7462    }
7463
7464    fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
7465        let body: String = lines
7466            .iter()
7467            .map(|l| format!("{l}\n"))
7468            .collect::<Vec<_>>()
7469            .join("");
7470        ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
7471    }
7472
7473    /// Probe (stream:false) answers with plain content; the streaming re-issue
7474    /// (stream:true) returns NDJSON tokens with usage on the `done` chunk.
7475    struct StreamHappyResponder;
7476    impl Respond for StreamHappyResponder {
7477        fn respond(&self, req: &Request) -> ResponseTemplate {
7478            if is_stream(req) {
7479                ndjson(&[
7480                    serde_json::json!({"message": {"content": "Hello "}, "done": false}),
7481                    serde_json::json!({
7482                        "message": {"content": "world"}, "done": true,
7483                        "prompt_eval_count": 7, "eval_count": 3
7484                    }),
7485                ])
7486            } else {
7487                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7488                    "message": {"content": "probe answer"},
7489                    "prompt_eval_count": 5, "eval_count": 2,
7490                }))
7491            }
7492        }
7493    }
7494
7495    #[tokio::test]
7496    async fn ollama_streams_final_answer_and_merges_usage() {
7497        let server = MockServer::start().await;
7498        Mock::given(method("POST"))
7499            .and(path("/api/chat"))
7500            .respond_with(StreamHappyResponder)
7501            .mount(&server)
7502            .await;
7503
7504        let messages = msgs();
7505        let caveats = Caveats::top();
7506        let (reply, streamed, usage, hallu) =
7507            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7508                .await
7509                .expect("chat_complete should succeed");
7510
7511        assert_eq!(reply, "Hello world", "tokens accumulated across chunks");
7512        assert!(streamed, "the streaming path printed the tokens");
7513        let u = usage.expect("probe + stream usage merged");
7514        // SEMANTICS CHANGED in Step 18.1: both requests carried the same
7515        // conversation, so input is max(5, 7) = 7 — the old sum (12) counted
7516        // the shared history twice. Output is still 2 + 3 (new generation).
7517        assert_eq!(u.input_tokens, 7, "max(5 probe, 7 stream), not the sum");
7518        assert_eq!(u.output_tokens, 5, "2 (probe) + 3 (stream)");
7519        assert_eq!(hallu, 0);
7520    }
7521
7522    #[test]
7523    fn detects_tools_unsupported_400_phrasings() {
7524        assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7525            "Ollama 400 Bad Request: registry.ollama.ai/library/deepseek-r1:70b does not support tools"
7526        )));
7527        // Looser OpenAI-compatible phrasing.
7528        assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7529            "this model does not support tools at this time"
7530        )));
7531        // Unrelated 400s must NOT trip the no-tools path.
7532        assert!(!is_tools_unsupported_error(&anyhow::anyhow!(
7533            "Ollama 400 Bad Request: context window exceeded"
7534        )));
7535    }
7536
7537    #[test]
7538    fn detects_ollama_tool_xml_parser_errors() {
7539        assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7540            "{}",
7541            r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 7: element \u003cparameter\u003e closed by \u003c/function\u003e"}"#
7542        )));
7543        assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7544            "{}",
7545            r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 2: element <parameter> closed by </function>"}"#
7546        )));
7547        assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7548            "{}",
7549            r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 3: unexpected end element \u003c/parameter\u003e"}"#
7550        )));
7551        assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7552            "Ollama 500 Internal Server Error: model runner crashed"
7553        )));
7554        assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7555            "OpenAI 400 Bad Request: XML syntax error in user supplied file"
7556        )));
7557    }
7558
7559    /// A model that rejects the `tools` field (deepseek-r1) 400s on the first
7560    /// dispatch; newt must drop tools and re-dispatch, answering normally. The
7561    /// tools-absent retry is the one that succeeds — no tools-400 loop.
7562    struct NoToolsResponder {
7563        rejections: Arc<AtomicUsize>,
7564        served_without_tools: Arc<AtomicBool>,
7565    }
7566    impl Respond for NoToolsResponder {
7567        fn respond(&self, req: &Request) -> ResponseTemplate {
7568            let has_tools = body_json(req).get("tools").is_some();
7569            if has_tools {
7570                self.rejections.fetch_add(1, Ordering::SeqCst);
7571                return ResponseTemplate::new(400).set_body_string(
7572                    "registry.ollama.ai/library/deepseek-r1:70b does not support tools",
7573                );
7574            }
7575            self.served_without_tools.store(true, Ordering::SeqCst);
7576            if is_stream(req) {
7577                ndjson(&[serde_json::json!({
7578                    "message": {"content": "hello there"}, "done": true,
7579                    "prompt_eval_count": 4, "eval_count": 2
7580                })])
7581            } else {
7582                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7583                    "message": {"content": "probe answer"},
7584                    "prompt_eval_count": 4, "eval_count": 2,
7585                }))
7586            }
7587        }
7588    }
7589
7590    #[tokio::test]
7591    async fn no_tools_model_recovers_by_dropping_tools() {
7592        let server = MockServer::start().await;
7593        let rejections = Arc::new(AtomicUsize::new(0));
7594        let served_without_tools = Arc::new(AtomicBool::new(false));
7595        Mock::given(method("POST"))
7596            .and(path("/api/chat"))
7597            .respond_with(NoToolsResponder {
7598                rejections: rejections.clone(),
7599                served_without_tools: served_without_tools.clone(),
7600            })
7601            .mount(&server)
7602            .await;
7603
7604        let messages = msgs();
7605        let caveats = Caveats::top();
7606        let (reply, streamed, _usage, _) =
7607            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7608                .await
7609                .expect("a no-tools model still answers a bare prompt");
7610
7611        assert_eq!(reply, "hello there", "the tools-absent retry answered");
7612        assert!(streamed);
7613        assert!(
7614            served_without_tools.load(Ordering::SeqCst),
7615            "a request without the tools field was eventually served"
7616        );
7617        assert_eq!(
7618            rejections.load(Ordering::SeqCst),
7619            1,
7620            "exactly one tools-bearing request 400s — the drop is self-limiting"
7621        );
7622    }
7623
7624    /// Ollama can 500 before returning assistant content when its XML parser
7625    /// sees malformed Qwen-style tool-call tags. That is not the same as
7626    /// "model does not support tools": Newt should retry with tools still
7627    /// advertised so the model can make forward progress on the next round.
7628    struct MalformedToolXmlResponder {
7629        rejections: Arc<AtomicUsize>,
7630        served_with_tools_after_error: Arc<AtomicBool>,
7631        served_without_tools: Arc<AtomicBool>,
7632    }
7633    impl Respond for MalformedToolXmlResponder {
7634        fn respond(&self, req: &Request) -> ResponseTemplate {
7635            if body_json(req).get("tools").is_some() {
7636                if self.rejections.fetch_add(1, Ordering::SeqCst) == 0 {
7637                    return ResponseTemplate::new(500).set_body_json(serde_json::json!({
7638                        "error": "XML syntax error on line 7: element <parameter> closed by </function>"
7639                    }));
7640                }
7641                self.served_with_tools_after_error
7642                    .store(true, Ordering::SeqCst);
7643                if is_stream(req) {
7644                    return ndjson(&[serde_json::json!({
7645                        "message": {"content": "recovered with tools still available"},
7646                        "done": true,
7647                        "prompt_eval_count": 4,
7648                        "eval_count": 3
7649                    })]);
7650                }
7651                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
7652                    "message": {"content": "probe answer with tools still available"},
7653                    "prompt_eval_count": 4,
7654                    "eval_count": 3,
7655                }));
7656            }
7657            self.served_without_tools.store(true, Ordering::SeqCst);
7658            if is_stream(req) {
7659                ndjson(&[serde_json::json!({
7660                    "message": {"content": "unexpected no-tools stream"},
7661                    "done": true,
7662                    "prompt_eval_count": 4, "eval_count": 3
7663                })])
7664            } else {
7665                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7666                    "message": {"content": "unexpected no-tools probe"},
7667                    "prompt_eval_count": 4, "eval_count": 3,
7668                }))
7669            }
7670        }
7671    }
7672
7673    #[tokio::test]
7674    async fn ollama_tool_xml_error_recovers_with_tools_still_available() {
7675        let server = MockServer::start().await;
7676        let rejections = Arc::new(AtomicUsize::new(0));
7677        let served_with_tools_after_error = Arc::new(AtomicBool::new(false));
7678        let served_without_tools = Arc::new(AtomicBool::new(false));
7679        Mock::given(method("POST"))
7680            .and(path("/api/chat"))
7681            .respond_with(MalformedToolXmlResponder {
7682                rejections: rejections.clone(),
7683                served_with_tools_after_error: served_with_tools_after_error.clone(),
7684                served_without_tools: served_without_tools.clone(),
7685            })
7686            .mount(&server)
7687            .await;
7688
7689        let messages = msgs();
7690        let caveats = Caveats::top();
7691        let (reply, streamed, _usage, _) =
7692            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7693                .await
7694                .expect("malformed XML tool-call parser errors should retry with tools");
7695
7696        assert_eq!(reply, "recovered with tools still available");
7697        assert!(streamed);
7698        assert!(
7699            served_with_tools_after_error.load(Ordering::SeqCst),
7700            "a tools-bearing request was served after the XML parser failure"
7701        );
7702        assert!(
7703            !served_without_tools.load(Ordering::SeqCst),
7704            "malformed XML must not disable tools for the turn"
7705        );
7706        assert_eq!(
7707            rejections.load(Ordering::SeqCst),
7708            3,
7709            "the XML error probe, retry probe, and streaming re-issue all keep tools advertised"
7710        );
7711    }
7712
7713    /// The streaming re-issue produces no tokens — the loop must fall back to
7714    /// the probe round's content rather than returning silence.
7715    struct EmptyStreamResponder;
7716    impl Respond for EmptyStreamResponder {
7717        fn respond(&self, req: &Request) -> ResponseTemplate {
7718            if is_stream(req) {
7719                ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
7720            } else {
7721                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7722                    "message": {"content": "probe says hi"},
7723                    "prompt_eval_count": 5, "eval_count": 2,
7724                }))
7725            }
7726        }
7727    }
7728
7729    #[tokio::test]
7730    async fn empty_stream_falls_back_to_probe_content() {
7731        let server = MockServer::start().await;
7732        Mock::given(method("POST"))
7733            .and(path("/api/chat"))
7734            .respond_with(EmptyStreamResponder)
7735            .mount(&server)
7736            .await;
7737
7738        let messages = msgs();
7739        let caveats = Caveats::top();
7740        let (reply, streamed, usage, _) =
7741            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7742                .await
7743                .expect("chat_complete should succeed");
7744
7745        assert_eq!(reply, "probe says hi");
7746        assert!(!streamed, "fallback content was never streamed");
7747        assert_eq!(usage.unwrap().input_tokens, 5);
7748    }
7749
7750    /// Regression for the DGX wedge: the non-streamed probe said "Let me verify
7751    /// by looking...", then the streaming re-issue returned no tokens. The probe
7752    /// fallback must still go through the no-tool nudge gate instead of ending
7753    /// the turn and forcing the operator to type "continue".
7754    struct EmptyStreamPendingActionResponder {
7755        probes: Arc<AtomicUsize>,
7756    }
7757    impl Respond for EmptyStreamPendingActionResponder {
7758        fn respond(&self, req: &Request) -> ResponseTemplate {
7759            if is_stream(req) {
7760                return ndjson(&[serde_json::json!({
7761                    "message": {"content": ""},
7762                    "done": true,
7763                    "prompt_eval_count": 6,
7764                    "eval_count": 0
7765                })]);
7766            }
7767            let probe = self.probes.fetch_add(1, Ordering::SeqCst);
7768            let content = if probe == 0 {
7769                "Now I understand the issue. Let me verify by looking at format_rollup_detail."
7770            } else {
7771                "Verified after the automatic continue."
7772            };
7773            ResponseTemplate::new(200).set_body_json(serde_json::json!({
7774                "message": {"content": content},
7775                "prompt_eval_count": 5 + probe as u32,
7776                "eval_count": 2,
7777            }))
7778        }
7779    }
7780
7781    #[tokio::test]
7782    async fn empty_stream_probe_fallback_pending_action_nudges_and_continues() {
7783        let server = MockServer::start().await;
7784        let probes = Arc::new(AtomicUsize::new(0));
7785        Mock::given(method("POST"))
7786            .and(path("/api/chat"))
7787            .respond_with(EmptyStreamPendingActionResponder {
7788                probes: probes.clone(),
7789            })
7790            .mount(&server)
7791            .await;
7792
7793        let messages = msgs();
7794        let caveats = Caveats::top();
7795        let (reply, streamed, _usage, _) =
7796            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7797                .await
7798                .expect("chat_complete should auto-continue after pending probe fallback");
7799
7800        assert_eq!(
7801            probes.load(Ordering::SeqCst),
7802            2,
7803            "the nudge ran a second probe"
7804        );
7805        assert_eq!(reply, "Verified after the automatic continue.");
7806        assert!(!streamed, "the second answer also came from probe fallback");
7807        assert!(
7808            !reply.contains("Let me verify"),
7809            "must not return the pending-action narration"
7810        );
7811    }
7812
7813    /// Probe AND stream both empty, with no safe-context hint → the loop gives
7814    /// the explicit empty-response diagnostic instead of silence.
7815    struct AllEmptyResponder;
7816    impl Respond for AllEmptyResponder {
7817        fn respond(&self, req: &Request) -> ResponseTemplate {
7818            if is_stream(req) {
7819                ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
7820            } else {
7821                ResponseTemplate::new(200)
7822                    .set_body_json(serde_json::json!({"message": {"content": ""}}))
7823            }
7824        }
7825    }
7826
7827    #[tokio::test]
7828    async fn fully_empty_response_yields_diagnostic_message() {
7829        let server = MockServer::start().await;
7830        Mock::given(method("POST"))
7831            .and(path("/api/chat"))
7832            .respond_with(AllEmptyResponder)
7833            .mount(&server)
7834            .await;
7835
7836        let messages = msgs();
7837        let caveats = Caveats::top();
7838        let (reply, streamed, _, _) =
7839            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7840                .await
7841                .expect("chat_complete should succeed");
7842
7843        assert!(
7844            reply.contains("model returned an empty response"),
7845            "got: {reply}"
7846        );
7847        assert!(reply.contains("newt doctor"), "points at diagnostics");
7848        assert!(!streamed);
7849    }
7850
7851    struct SuspiciousEmptyThenRecover {
7852        probes: Arc<AtomicUsize>,
7853        saw_nudge: Arc<AtomicBool>,
7854    }
7855    impl Respond for SuspiciousEmptyThenRecover {
7856        fn respond(&self, req: &Request) -> ResponseTemplate {
7857            if is_stream(req) {
7858                if self.probes.load(Ordering::SeqCst) <= 1 {
7859                    ndjson(&[serde_json::json!({
7860                        "message": {"content": ""},
7861                        "done": true,
7862                        "prompt_eval_count": 9,
7863                        "eval_count": 4
7864                    })])
7865                } else {
7866                    ndjson(&[
7867                        serde_json::json!({"message": {"content": "recovered "}, "done": false}),
7868                        serde_json::json!({
7869                            "message": {"content": "after empty retry"},
7870                            "done": true,
7871                            "prompt_eval_count": 5,
7872                            "eval_count": 3
7873                        }),
7874                    ])
7875                }
7876            } else {
7877                let body = body_json(req);
7878                if body["messages"].as_array().into_iter().flatten().any(|m| {
7879                    m["content"]
7880                        .as_str()
7881                        .unwrap_or("")
7882                        .contains("no assistant-visible content")
7883                }) {
7884                    self.saw_nudge.store(true, Ordering::SeqCst);
7885                }
7886                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
7887                if n == 1 {
7888                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7889                        "message": {
7890                            "content": "",
7891                            "thinking": "I know what to do but did not emit final text."
7892                        },
7893                        "prompt_eval_count": 10,
7894                        "eval_count": 2559,
7895                    }))
7896                } else {
7897                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7898                        "message": {"content": "recovered after empty retry"},
7899                        "prompt_eval_count": 5,
7900                        "eval_count": 3,
7901                    }))
7902                }
7903            }
7904        }
7905    }
7906
7907    #[tokio::test]
7908    async fn suspicious_empty_generated_output_retries_with_nudge() {
7909        let server = MockServer::start().await;
7910        let probes = Arc::new(AtomicUsize::new(0));
7911        let saw_nudge = Arc::new(AtomicBool::new(false));
7912        Mock::given(method("POST"))
7913            .and(path("/api/chat"))
7914            .respond_with(SuspiciousEmptyThenRecover {
7915                probes: probes.clone(),
7916                saw_nudge: saw_nudge.clone(),
7917            })
7918            .mount(&server)
7919            .await;
7920
7921        let messages = msgs();
7922        let caveats = Caveats::top();
7923        let (reply, streamed, usage, _) =
7924            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7925                .await
7926                .expect("chat_complete should succeed");
7927
7928        assert_eq!(reply, "recovered after empty retry");
7929        assert!(streamed);
7930        assert_eq!(probes.load(Ordering::SeqCst), 2);
7931        assert!(saw_nudge.load(Ordering::SeqCst));
7932        assert!(
7933            usage
7934                .expect("usage survives suspicious retry")
7935                .output_tokens
7936                >= 2566,
7937            "usage from the suspicious empty round must be preserved"
7938        );
7939    }
7940
7941    struct SuspiciousEmptyTwiceThenRecover {
7942        probes: Arc<AtomicUsize>,
7943        saw_strong_nudge: Arc<AtomicBool>,
7944    }
7945    impl Respond for SuspiciousEmptyTwiceThenRecover {
7946        fn respond(&self, req: &Request) -> ResponseTemplate {
7947            if is_stream(req) {
7948                if self.probes.load(Ordering::SeqCst) <= 2 {
7949                    ndjson(&[serde_json::json!({
7950                        "message": {"content": ""},
7951                        "done": true,
7952                        "prompt_eval_count": 9,
7953                        "eval_count": 4
7954                    })])
7955                } else {
7956                    ndjson(&[serde_json::json!({
7957                        "message": {"content": "recovered after strong hidden-only nudge"},
7958                        "done": true,
7959                        "prompt_eval_count": 5,
7960                        "eval_count": 3
7961                    })])
7962                }
7963            } else {
7964                let body = body_json(req);
7965                if body["messages"].as_array().into_iter().flatten().any(|m| {
7966                    m["content"]
7967                        .as_str()
7968                        .unwrap_or("")
7969                        .contains("Hidden thinking is not an action")
7970                }) {
7971                    self.saw_strong_nudge.store(true, Ordering::SeqCst);
7972                }
7973                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
7974                if n <= 2 {
7975                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7976                        "message": {
7977                            "content": "",
7978                            "thinking": "I know the next action but did not emit it."
7979                        },
7980                        "prompt_eval_count": 10,
7981                        "eval_count": 2559,
7982                    }))
7983                } else {
7984                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7985                        "message": {"content": "recovered after strong hidden-only nudge"},
7986                        "prompt_eval_count": 5,
7987                        "eval_count": 3,
7988                    }))
7989                }
7990            }
7991        }
7992    }
7993
7994    #[tokio::test]
7995    async fn repeated_thinking_only_gets_stronger_second_nudge() {
7996        let server = MockServer::start().await;
7997        let probes = Arc::new(AtomicUsize::new(0));
7998        let saw_strong_nudge = Arc::new(AtomicBool::new(false));
7999        Mock::given(method("POST"))
8000            .and(path("/api/chat"))
8001            .respond_with(SuspiciousEmptyTwiceThenRecover {
8002                probes: probes.clone(),
8003                saw_strong_nudge: saw_strong_nudge.clone(),
8004            })
8005            .mount(&server)
8006            .await;
8007
8008        let messages = msgs();
8009        let caveats = Caveats::top();
8010        let (reply, streamed, _, _) =
8011            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8012                .await
8013                .expect("second hidden-only nudge should recover the turn");
8014
8015        assert_eq!(reply, "recovered after strong hidden-only nudge");
8016        assert!(streamed);
8017        assert_eq!(probes.load(Ordering::SeqCst), 3);
8018        assert!(saw_strong_nudge.load(Ordering::SeqCst));
8019    }
8020
8021    struct SuspiciousEmptyStaysEmpty;
8022    impl Respond for SuspiciousEmptyStaysEmpty {
8023        fn respond(&self, req: &Request) -> ResponseTemplate {
8024            if is_stream(req) {
8025                ndjson(&[serde_json::json!({
8026                    "message": {"content": ""},
8027                    "done": true,
8028                    "prompt_eval_count": 9,
8029                    "eval_count": 4
8030                })])
8031            } else {
8032                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8033                    "message": {
8034                        "content": "",
8035                        "reasoning_content": "internal-only response"
8036                    },
8037                    "prompt_eval_count": 10,
8038                    "eval_count": 12,
8039                }))
8040            }
8041        }
8042    }
8043
8044    #[tokio::test]
8045    async fn suspicious_empty_generated_output_reports_targeted_diagnostic() {
8046        let server = MockServer::start().await;
8047        Mock::given(method("POST"))
8048            .and(path("/api/chat"))
8049            .respond_with(SuspiciousEmptyStaysEmpty)
8050            .mount(&server)
8051            .await;
8052
8053        let messages = msgs();
8054        let caveats = Caveats::top();
8055        let uri = server.uri();
8056        let mut c = ctx(&uri, &messages, &caveats);
8057        c.trace = true;
8058        let (reply, streamed, _, _) = chat_complete(c, &mut NoMcp)
8059            .await
8060            .expect("chat_complete should succeed");
8061
8062        assert!(reply.contains("generated output tokens"), "got: {reply}");
8063        assert!(
8064            reply.contains("reasoning_content"),
8065            "diagnostic should name the non-content field: {reply}"
8066        );
8067        assert!(reply.contains("--trace"), "points at trace diagnostics");
8068        assert!(!streamed);
8069    }
8070
8071    /// First round: empty content with token usage near the safe-context
8072    /// ceiling → the loop must emit the overflow notice, trim, and retry.
8073    /// Second round: a real answer.
8074    struct OverflowThenRecover {
8075        probes: Arc<AtomicUsize>,
8076    }
8077    impl Respond for OverflowThenRecover {
8078        fn respond(&self, req: &Request) -> ResponseTemplate {
8079            if is_stream(req) {
8080                // Streams mirror the probe sequence: empty first, content after.
8081                if self.probes.load(Ordering::SeqCst) <= 1 {
8082                    ndjson(&[serde_json::json!({
8083                        "message": {"content": ""}, "done": true,
8084                        "prompt_eval_count": 90, "eval_count": 1
8085                    })])
8086                } else {
8087                    ndjson(&[
8088                        serde_json::json!({"message": {"content": "recovered "}, "done": false}),
8089                        serde_json::json!({
8090                            "message": {"content": "after trim"}, "done": true,
8091                            "prompt_eval_count": 12, "eval_count": 4
8092                        }),
8093                    ])
8094                }
8095            } else {
8096                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8097                if n == 1 {
8098                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8099                        "message": {"content": ""},
8100                        "prompt_eval_count": 90, "eval_count": 1,
8101                    }))
8102                } else {
8103                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8104                        "message": {"content": "recovered after trim"},
8105                        "prompt_eval_count": 12, "eval_count": 4,
8106                    }))
8107                }
8108            }
8109        }
8110    }
8111
8112    #[tokio::test]
8113    async fn context_overflow_trims_and_retries_then_recovers() {
8114        let server = MockServer::start().await;
8115        let probes = Arc::new(AtomicUsize::new(0));
8116        Mock::given(method("POST"))
8117            .and(path("/api/chat"))
8118            .respond_with(OverflowThenRecover {
8119                probes: probes.clone(),
8120            })
8121            .mount(&server)
8122            .await;
8123
8124        let messages = msgs();
8125        let caveats = Caveats::top();
8126        let uri = server.uri();
8127        let mut c = ctx(&uri, &messages, &caveats);
8128        // Safe window of 100 input tokens: the empty round reported a 90-token
8129        // prompt, and 90 ≥ 85% of 100, so it is classified as likely overflow.
8130        // (Step 18.1: the check compares the largest single prompt against the
8131        // window — the old multi-round sum, 180 here, inflated past 85% after
8132        // two rounds on EVERY long turn, firing spurious overflow retries.)
8133        c.safe_context = Some(100);
8134        let (reply, streamed, usage, _) = chat_complete(c, &mut NoMcp)
8135            .await
8136            .expect("chat_complete should succeed");
8137
8138        assert_eq!(
8139            probes.load(Ordering::SeqCst),
8140            2,
8141            "overflow must trigger exactly one trim-and-retry probe"
8142        );
8143        assert_eq!(reply, "recovered after trim");
8144        assert!(streamed);
8145        assert_eq!(
8146            usage
8147                .expect("accumulated usage survives the retry")
8148                .input_tokens,
8149            90,
8150            "largest single prompt across the overflowed + recovered rounds"
8151        );
8152    }
8153
8154    /// Tool calls every round with a tiny trim threshold: the mid-loop
8155    /// compression must fire — observable as the compaction marker (NOT the
8156    /// old amputation placeholder) reaching the model. With no summarizer
8157    /// injected, this is the static-fallback path (Step 18.4).
8158    struct TrimObservingResponder {
8159        marker_seen: Arc<AtomicBool>,
8160        old_placeholder_seen: Arc<AtomicBool>,
8161    }
8162    impl Respond for TrimObservingResponder {
8163        fn respond(&self, req: &Request) -> ResponseTemplate {
8164            let body = body_json(req);
8165            let contains = |needle: &str| {
8166                body["messages"]
8167                    .as_array()
8168                    .map(|m| {
8169                        m.iter().any(|msg| {
8170                            msg["content"]
8171                                .as_str()
8172                                .map(|c| c.contains(needle))
8173                                .unwrap_or(false)
8174                        })
8175                    })
8176                    .unwrap_or(false)
8177            };
8178            if contains(SUMMARY_PREFIX) && contains("Summary generation was unavailable.") {
8179                self.marker_seen.store(true, Ordering::SeqCst);
8180            }
8181            if contains("earlier tool-call messages omitted") {
8182                self.old_placeholder_seen.store(true, Ordering::SeqCst);
8183            }
8184            if body.get("tools").is_some() {
8185                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8186                    "message": {"content": "", "tool_calls": [{
8187                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8188                    }]}
8189                }))
8190            } else {
8191                ResponseTemplate::new(200)
8192                    .set_body_json(serde_json::json!({"message": {"content": "final after trim"}}))
8193            }
8194        }
8195    }
8196
8197    #[tokio::test]
8198    async fn mid_loop_compression_fires_when_message_list_grows() {
8199        let server = MockServer::start().await;
8200        let marker_seen = Arc::new(AtomicBool::new(false));
8201        let old_placeholder_seen = Arc::new(AtomicBool::new(false));
8202        Mock::given(method("POST"))
8203            .and(path("/api/chat"))
8204            .respond_with(TrimObservingResponder {
8205                marker_seen: marker_seen.clone(),
8206                old_placeholder_seen: old_placeholder_seen.clone(),
8207            })
8208            .mount(&server)
8209            .await;
8210
8211        let messages = msgs();
8212        let caveats = Caveats::top();
8213        let uri = server.uri();
8214        let mut c = ctx(&uri, &messages, &caveats);
8215        c.max_tool_rounds = 3;
8216        c.mid_loop_trim_threshold = 4;
8217        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8218            .await
8219            .expect("chat_complete should succeed");
8220
8221        assert!(
8222            marker_seen.load(Ordering::SeqCst),
8223            "the static compaction marker must have reached the model mid-loop"
8224        );
8225        assert!(
8226            !old_placeholder_seen.load(Ordering::SeqCst),
8227            "the pre-18.4 amputation placeholder must never be emitted"
8228        );
8229        assert_eq!(reply, "final after trim");
8230    }
8231
8232    /// The cap-exit summary round returns 200 with EMPTY content: the loop
8233    /// must surface the named fallback, not the empty string.
8234    struct EmptyFinalSummary;
8235    impl Respond for EmptyFinalSummary {
8236        fn respond(&self, req: &Request) -> ResponseTemplate {
8237            if body_json(req).get("tools").is_some() {
8238                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8239                    "message": {"content": "", "tool_calls": [{
8240                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8241                    }]}
8242                }))
8243            } else {
8244                ResponseTemplate::new(200)
8245                    .set_body_json(serde_json::json!({"message": {"content": ""}}))
8246            }
8247        }
8248    }
8249
8250    #[tokio::test]
8251    async fn empty_final_summary_yields_cap_fallback() {
8252        let server = MockServer::start().await;
8253        Mock::given(method("POST"))
8254            .and(path("/api/chat"))
8255            .respond_with(EmptyFinalSummary)
8256            .mount(&server)
8257            .await;
8258
8259        let messages = msgs();
8260        let caveats = Caveats::top();
8261        let uri = server.uri();
8262        let mut c = ctx(&uri, &messages, &caveats);
8263        c.max_tool_rounds = 2;
8264        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8265            .await
8266            .expect("chat_complete should succeed");
8267
8268        assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
8269        assert!(reply.contains("max_tool_rounds"), "names the knob");
8270    }
8271
8272    /// #867 regression: the cap-exit summary cites a file that does not
8273    /// exist (the forensic transcript's exact shape — evidence trimmed, the
8274    /// model reconstructs a plausible path). The claim check must append a
8275    /// visible refutation naming the path, while leaving the model's prose
8276    /// intact as a prefix.
8277    struct HallucinatingFinalSummary;
8278    impl Respond for HallucinatingFinalSummary {
8279        fn respond(&self, req: &Request) -> ResponseTemplate {
8280            if body_json(req).get("tools").is_some() {
8281                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8282                    "message": {"content": "", "tool_calls": [{
8283                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8284                    }]}
8285                }))
8286            } else {
8287                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8288                    "message": {"content":
8289                        "The /end command is defined in newt-tui/src/commands.rs \
8290                         (lines 38-40) as enum variants."}
8291                }))
8292            }
8293        }
8294    }
8295
8296    #[tokio::test]
8297    async fn cap_exit_hallucinated_path_gets_claim_check_refutation() {
8298        let server = MockServer::start().await;
8299        Mock::given(method("POST"))
8300            .and(path("/api/chat"))
8301            .respond_with(HallucinatingFinalSummary)
8302            .mount(&server)
8303            .await;
8304
8305        let messages = msgs();
8306        let caveats = Caveats::top();
8307        let uri = server.uri();
8308        let mut c = ctx(&uri, &messages, &caveats);
8309        c.max_tool_rounds = 2;
8310        // `ctx` sets workspace = "." (this crate's dir under cargo test), so
8311        // the cited `newt-tui/src/commands.rs` provably does not exist there.
8312        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8313            .await
8314            .expect("chat_complete should succeed");
8315
8316        assert!(
8317            reply.contains("newt-tui/src/commands.rs (lines 38-40)"),
8318            "the model's prose is preserved verbatim: {reply}"
8319        );
8320        assert!(reply.contains("⚠ claim check (#867)"), "got: {reply}");
8321        assert!(
8322            reply.contains("`newt-tui/src/commands.rs`"),
8323            "the fabricated path is named in the refutation: {reply}"
8324        );
8325    }
8326
8327    // -----------------------------------------------------------------------
8328    // OpenAI-path coverage
8329    // -----------------------------------------------------------------------
8330
8331    #[tokio::test]
8332    async fn chat_complete_dispatches_openai_kind_and_returns_first_round_answer() {
8333        let server = MockServer::start().await;
8334        Mock::given(method("POST"))
8335            .and(path("/v1/chat/completions"))
8336            .and(header("authorization", "Bearer sk-test"))
8337            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8338                "choices": [{"message": {"content": "openai says hi"}}],
8339                "usage": {"prompt_tokens": 10, "completion_tokens": 4},
8340            })))
8341            .mount(&server)
8342            .await;
8343
8344        let messages = msgs();
8345        let caveats = Caveats::top();
8346        let uri = server.uri();
8347        let mut c = ctx(&uri, &messages, &caveats);
8348        c.kind = BackendKind::Openai;
8349        c.api_key = Some("sk-test");
8350        // Calling chat_complete (not openai_chat_complete) pins the dispatch.
8351        let (reply, streamed, usage, hallu) = chat_complete(c, &mut NoMcp)
8352            .await
8353            .expect("openai dispatch should succeed");
8354
8355        assert_eq!(reply, "openai says hi");
8356        assert!(!streamed, "openai path is non-streaming");
8357        let u = usage.unwrap();
8358        assert_eq!((u.input_tokens, u.output_tokens), (10, 4));
8359        assert_eq!(hallu, 0);
8360    }
8361
8362    #[tokio::test]
8363    async fn openai_strips_inline_think_and_never_returns_reasoning_content() {
8364        // #857: a reasoning model served with the parser OFF puts its CoT inline as
8365        // <think>…</think> in content; served with the parser ON it lands in a
8366        // separate reasoning_content field. Either way the returned answer must be
8367        // ONLY the clean content — no <think> markers, no CoT, no reasoning_content.
8368        let server = MockServer::start().await;
8369        Mock::given(method("POST"))
8370            .and(path("/v1/chat/completions"))
8371            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8372                "choices": [{"message": {
8373                    "content": "<think>secret chain of thought</think>The final answer.",
8374                    "reasoning_content": "separate-channel reasoning"
8375                }}]
8376            })))
8377            .mount(&server)
8378            .await;
8379
8380        let messages = msgs();
8381        let caveats = Caveats::top();
8382        let uri = server.uri();
8383        let mut c = ctx(&uri, &messages, &caveats);
8384        c.kind = BackendKind::Openai;
8385        let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
8386            .await
8387            .expect("openai dispatch should succeed");
8388
8389        assert_eq!(reply, "The final answer.", "answer is the stripped content");
8390        assert!(!reply.contains("<think>"), "no think markers: {reply}");
8391        assert!(
8392            !reply.contains("secret chain of thought"),
8393            "inline CoT must not leak: {reply}"
8394        );
8395        assert!(
8396            !reply.contains("separate-channel reasoning"),
8397            "reasoning_content must not leak into the reply: {reply}"
8398        );
8399    }
8400
8401    #[tokio::test]
8402    async fn openai_empty_content_yields_diagnostic_message() {
8403        let server = MockServer::start().await;
8404        Mock::given(method("POST"))
8405            .and(path("/v1/chat/completions"))
8406            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8407                "choices": [{"message": {"content": ""}}]
8408            })))
8409            .mount(&server)
8410            .await;
8411
8412        let messages = msgs();
8413        let caveats = Caveats::top();
8414        let uri = server.uri();
8415        let mut c = ctx(&uri, &messages, &caveats);
8416        c.kind = BackendKind::Openai;
8417        let (reply, _, _, _) = chat_complete(c, &mut NoMcp).await.expect("should succeed");
8418        assert!(
8419            reply.contains("model returned an empty response"),
8420            "got: {reply}"
8421        );
8422    }
8423
8424    /// Mock MCP that handles exactly one namespaced tool for routing tests.
8425    struct OneToolMcp {
8426        name: &'static str,
8427        result: &'static str,
8428    }
8429    #[async_trait::async_trait]
8430    impl McpTools for OneToolMcp {
8431        fn handles(&self, name: &str) -> bool {
8432            name == self.name
8433        }
8434        fn tool_defs(&self) -> Vec<serde_json::Value> {
8435            Vec::new()
8436        }
8437        async fn call(&mut self, _name: &str, _args: &serde_json::Value) -> String {
8438            self.result.to_string()
8439        }
8440    }
8441
8442    /// An API proxy may put Anthropic-native tool-use blocks
8443    /// (`{"name":"…","input":{}}`) inside the OpenAI `tool_calls` array
8444    /// instead of converting them to `{"function":{"name":"…","arguments":"…"}}`.
8445    /// The loop must detect the missing `function` key, fall back to the
8446    /// Anthropic-native fields, and route the call correctly.
8447    #[tokio::test]
8448    async fn openai_anthropic_native_tool_calls_route_correctly() {
8449        let server = MockServer::start().await;
8450        // Round 1: Anthropic-native tool-use block in the tool_calls array.
8451        // Round 2: plain text final answer after receiving the tool result.
8452        let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8453        let cc = call_count.clone();
8454        Mock::given(method("POST"))
8455            .and(path("/v1/chat/completions"))
8456            .respond_with(move |_req: &Request| {
8457                let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8458                if n == 0 {
8459                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8460                        "choices": [{"message": {
8461                            "content": null,
8462                            "tool_calls": [{
8463                                "type": "tool_use",
8464                                "id": "toolu_01ABC",
8465                                "name": "my_server__my_tool",
8466                                "input": {"key": "value"}
8467                            }]
8468                        }}],
8469                        "usage": {"prompt_tokens": 50, "completion_tokens": 10}
8470                    }))
8471                } else {
8472                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8473                        "choices": [{"message": {"content": "done after anthropic-native tool"}}],
8474                        "usage": {"prompt_tokens": 60, "completion_tokens": 8}
8475                    }))
8476                }
8477            })
8478            .mount(&server)
8479            .await;
8480
8481        let messages = msgs();
8482        let caveats = Caveats::top();
8483        let uri = server.uri();
8484        let mut c = ctx(&uri, &messages, &caveats);
8485        c.kind = BackendKind::Openai;
8486        let mut mcp = OneToolMcp {
8487            name: "my_server__my_tool",
8488            result: "tool-result-text",
8489        };
8490        let (reply, _, _, hallu) = chat_complete(c, &mut mcp)
8491            .await
8492            .expect("should succeed with anthropic-native tool format");
8493        assert_eq!(reply, "done after anthropic-native tool");
8494        assert_eq!(hallu, 0, "should not be counted as hallucination");
8495        assert_eq!(
8496            call_count.load(std::sync::atomic::Ordering::SeqCst),
8497            2,
8498            "must have done both rounds (tool call + final answer)"
8499        );
8500    }
8501
8502    /// Regression: some OpenAI-compatible API proxies normalise hyphens to
8503    /// underscores in tool names (`acme-server` → `acme_server`).  Verify that
8504    /// the underscore form routes through MCP rather than falling to "unknown tool".
8505    #[tokio::test]
8506    async fn openai_hyphenated_server_name_routes_through_mcp() {
8507        let server = MockServer::start().await;
8508        let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8509        let cc = call_count.clone();
8510        Mock::given(method("POST"))
8511            .and(path("/v1/chat/completions"))
8512            .respond_with(move |_req: &Request| {
8513                let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8514                if n == 0 {
8515                    // Proxy returns the underscore-normalised form of the
8516                    // server prefix even though we advertised hyphens.
8517                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8518                        "choices": [{"message": {
8519                            "content": "",
8520                            "tool_calls": [{
8521                                "index": 0,
8522                                "function": {
8523                                    "arguments": "{}",
8524                                    "name": "acme_server__probe_tool"
8525                                },
8526                                "id": "call_probe_01",
8527                                "type": "function"
8528                            }]
8529                        }}],
8530                        "usage": {"prompt_tokens": 599, "completion_tokens": 30}
8531                    }))
8532                } else {
8533                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8534                        "choices": [{"message": {"content": "outlook routed correctly"}}]
8535                    }))
8536                }
8537            })
8538            .mount(&server)
8539            .await;
8540
8541        let messages = msgs();
8542        let caveats = Caveats::top();
8543        let uri = server.uri();
8544        let mut c = ctx(&uri, &messages, &caveats);
8545        c.kind = BackendKind::Openai;
8546        // OneToolMcp.handles() must match the underscore form the proxy returns.
8547        let mut mcp = OneToolMcp {
8548            name: "acme_server__probe_tool",
8549            result: "ok",
8550        };
8551        let (reply, _, _, _) = chat_complete(c, &mut mcp)
8552            .await
8553            .expect("should route hyphenated server name through mcp");
8554        assert_eq!(reply, "outlook routed correctly");
8555        assert_eq!(
8556            call_count.load(std::sync::atomic::Ordering::SeqCst),
8557            2,
8558            "must have completed both rounds"
8559        );
8560    }
8561
8562    /// OpenAI mirror of the Ollama cap-exit fallback: tool calls until the cap,
8563    /// then a 400 on the tools-disabled summary → the named fallback.
8564    struct OpenAiErrOnFinal;
8565    impl Respond for OpenAiErrOnFinal {
8566        fn respond(&self, req: &Request) -> ResponseTemplate {
8567            if body_json(req).get("tools").is_some() {
8568                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8569                    "choices": [{"message": {
8570                        "content": null,
8571                        "tool_calls": [{
8572                            "id": "call_1",
8573                            "type": "function",
8574                            "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
8575                        }]
8576                    }}]
8577                }))
8578            } else {
8579                ResponseTemplate::new(400).set_body_string("bad request")
8580            }
8581        }
8582    }
8583
8584    #[tokio::test]
8585    async fn openai_cap_exit_fallback_when_final_summary_errors() {
8586        let server = MockServer::start().await;
8587        Mock::given(method("POST"))
8588            .and(path("/v1/chat/completions"))
8589            .respond_with(OpenAiErrOnFinal)
8590            .mount(&server)
8591            .await;
8592
8593        let messages = msgs();
8594        let caveats = Caveats::top();
8595        let uri = server.uri();
8596        let mut c = ctx(&uri, &messages, &caveats);
8597        c.kind = BackendKind::Openai;
8598        c.max_tool_rounds = 2;
8599        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8600            .await
8601            .expect("must succeed even when the summary errors");
8602        assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
8603        assert!(reply.contains("max_tool_rounds"));
8604    }
8605
8606    // -- Narrate-then-stop rescue: bounded no-tool-call auto-continue ---------
8607
8608    /// OpenAI responder that serves a scripted `choices[0].message` per request
8609    /// (by order); out-of-range requests repeat the last scripted entry.
8610    struct ScriptedOpenAi {
8611        round: Arc<AtomicUsize>,
8612        script: Vec<serde_json::Value>,
8613    }
8614    impl Respond for ScriptedOpenAi {
8615        fn respond(&self, _req: &Request) -> ResponseTemplate {
8616            let i = self.round.fetch_add(1, Ordering::SeqCst);
8617            let msg = self
8618                .script
8619                .get(i)
8620                .or_else(|| self.script.last())
8621                .cloned()
8622                .unwrap_or_else(|| serde_json::json!({ "content": "final." }));
8623            ResponseTemplate::new(200)
8624                .set_body_json(serde_json::json!({ "choices": [{ "message": msg }] }))
8625        }
8626    }
8627
8628    /// Drive the OpenAI loop over a per-round script; return `(reply, requests)`.
8629    async fn run_openai_script_with_ledger(
8630        script: Vec<serde_json::Value>,
8631        step_ledger: Option<&dyn StepLedger>,
8632    ) -> (String, usize) {
8633        let server = MockServer::start().await;
8634        let round = Arc::new(AtomicUsize::new(0));
8635        Mock::given(method("POST"))
8636            .and(path("/v1/chat/completions"))
8637            .respond_with(ScriptedOpenAi {
8638                round: round.clone(),
8639                script,
8640            })
8641            .mount(&server)
8642            .await;
8643        let messages = msgs();
8644        let caveats = Caveats::top();
8645        let uri = server.uri();
8646        let mut c = ctx(&uri, &messages, &caveats);
8647        c.kind = BackendKind::Openai;
8648        c.step_ledger = step_ledger;
8649        let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
8650        (reply, round.load(Ordering::SeqCst))
8651    }
8652
8653    async fn run_openai_script(script: Vec<serde_json::Value>) -> (String, usize) {
8654        run_openai_script_with_ledger(script, None).await
8655    }
8656
8657    #[tokio::test]
8658    async fn narrated_intent_with_no_tool_call_nudges_and_continues() {
8659        // The model narrates intent to act but calls no tool. Instead of ending
8660        // the turn (the bug), the loop nudges and runs another round, returning
8661        // the post-nudge answer.
8662        let (reply, rounds) = run_openai_script(vec![
8663            serde_json::json!({ "content": "Let me edit the file now." }),
8664            serde_json::json!({ "content": "All done — the edit is complete." }),
8665        ])
8666        .await;
8667        assert_eq!(rounds, 2, "must run a second round after the nudge");
8668        assert!(
8669            reply.contains("complete"),
8670            "returns the post-nudge answer: {reply}"
8671        );
8672        assert!(
8673            !reply.contains("Let me edit"),
8674            "must not return the narration: {reply}"
8675        );
8676    }
8677
8678    #[tokio::test]
8679    async fn narration_auto_continue_is_bounded_by_the_cap() {
8680        // The model narrates intent EVERY round. The cap (1) allows exactly one
8681        // nudge, then the narration is accepted as the final answer — no loop.
8682        let (reply, rounds) = run_openai_script(vec![
8683            serde_json::json!({ "content": "Let me keep editing now." }),
8684            serde_json::json!({ "content": "Let me keep editing now." }),
8685            serde_json::json!({ "content": "Let me keep editing now." }),
8686        ])
8687        .await;
8688        assert_eq!(
8689            rounds, 2,
8690            "exactly one nudge (cap=1), then accept, got {rounds}"
8691        );
8692        assert!(
8693            reply.contains("editing"),
8694            "narration accepted as final: {reply}"
8695        );
8696    }
8697
8698    #[tokio::test]
8699    async fn genuine_final_answer_is_not_nudged() {
8700        // No prior tool call and no intent-to-act cue → a real answer returns
8701        // immediately, un-nudged (no wasted round).
8702        let (reply, rounds) = run_openai_script(vec![
8703            serde_json::json!({ "content": "The capital of France is Paris." }),
8704        ])
8705        .await;
8706        assert_eq!(
8707            rounds, 1,
8708            "a plain final answer is not nudged, got {rounds}"
8709        );
8710        assert!(reply.contains("Paris"), "returns the answer: {reply}");
8711    }
8712
8713    #[tokio::test]
8714    async fn final_answer_after_a_tool_call_is_not_nudged() {
8715        // The normal "act, then conclude" turn: a tool call, then a cue-less
8716        // final answer. The rescue must NOT fire (no intent cue) — else every
8717        // ordinary tool-using turn would waste a round.
8718        let (reply, rounds) = run_openai_script(vec![
8719            serde_json::json!({
8720                "content": null,
8721                "tool_calls": [{
8722                    "id": "c1", "type": "function",
8723                    "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
8724                }]
8725            }),
8726            serde_json::json!({ "content": "The files were examined; everything checks out." }),
8727        ])
8728        .await;
8729        assert_eq!(
8730            rounds, 2,
8731            "tool call (r0) then final answer (r1) — no extra round, got {rounds}"
8732        );
8733        assert!(
8734            reply.contains("checks out"),
8735            "returns the final answer as-is: {reply}"
8736        );
8737    }
8738
8739    #[tokio::test]
8740    async fn observed_fix_intent_after_a_tool_call_nudges_and_continues() {
8741        // Live repro: after a read-only observation, the model identified the
8742        // exact edit but stopped on prose instead of calling the edit tool.
8743        let (reply, rounds) = run_openai_script(vec![
8744            serde_json::json!({
8745                "content": null,
8746                "tool_calls": [{
8747                    "id": "c1", "type": "function",
8748                    "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
8749                }]
8750            }),
8751            serde_json::json!({
8752                "content": "I found the issue - there's an extra closing brace } on line 809 of help_sections.rs that's causing a syntax error. I need to remove this stray brace."
8753            }),
8754            serde_json::json!({ "content": "The stray brace is removed and the compile error is fixed." }),
8755        ])
8756        .await;
8757        assert_eq!(
8758            rounds, 3,
8759            "tool call, narrated edit intent, then post-nudge answer; got {rounds}"
8760        );
8761        assert!(
8762            reply.contains("compile error is fixed"),
8763            "returns the post-nudge answer: {reply}"
8764        );
8765        assert!(
8766            !reply.contains("I need to remove"),
8767            "must not stop on the narrated edit intent: {reply}"
8768        );
8769    }
8770
8771    #[tokio::test]
8772    async fn pending_plan_final_answer_nudges_before_handoff() {
8773        let ledger = SessionStepLedger::default();
8774        ledger.restore(&PlanSnapshot {
8775            steps: vec![
8776                Step {
8777                    description: "convert help sections".to_string(),
8778                    status: StepStatus::Done,
8779                },
8780                Step {
8781                    description: "fix format_command_list and update lib.rs".to_string(),
8782                    status: StepStatus::Active,
8783                },
8784                Step {
8785                    description: "add tests".to_string(),
8786                    status: StepStatus::Todo,
8787                },
8788            ],
8789        });
8790        let (reply, rounds) = run_openai_script_with_ledger(
8791            vec![
8792                serde_json::json!({
8793                    "content": "I need to finish Step 2, then Steps 3-5."
8794                }),
8795                serde_json::json!({
8796                    "content": "Plan updated; continuing with the active step."
8797                }),
8798                serde_json::json!({
8799                    "content": "The active step is now complete."
8800                }),
8801            ],
8802            Some(&ledger as &dyn StepLedger),
8803        )
8804        .await;
8805        assert_eq!(
8806            rounds, 3,
8807            "open plan should force a completion-gate round and action-nudge follow-on narration"
8808        );
8809        assert!(
8810            reply.contains("complete"),
8811            "returns the post-nudge answer: {reply}"
8812        );
8813        assert!(
8814            !reply.contains("I need to finish"),
8815            "must not accept a plain handoff while plan is open: {reply}"
8816        );
8817    }
8818
8819    #[tokio::test]
8820    async fn findings_summary_with_stale_plan_nudges_update_plan_then_continues() {
8821        let ledger = SessionStepLedger::default();
8822        ledger.restore(&PlanSnapshot {
8823            steps: vec![
8824                Step {
8825                    description: "convert help sections".to_string(),
8826                    status: StepStatus::Done,
8827                },
8828                Step {
8829                    description: "wire progressive dispatch in lib.rs".to_string(),
8830                    status: StepStatus::Active,
8831                },
8832                Step {
8833                    description: "add tests".to_string(),
8834                    status: StepStatus::Todo,
8835                },
8836            ],
8837        });
8838        let findings = "\
8839Summary of Findings
8840
8841Across the tool calls, I observed two issues in newt-tui/src/help_sections.rs:
88421. Duplicate function definitions
88432. Stray closing brace
8844
8845Current Status
8846
8847The build is broken due to these syntax errors. The plan was at step 2, but we need to fix the immediate compilation issues first before proceeding with feature work.
8848
8849Next Steps Required
8850
8851To continue, I would need to remove the duplicate function using edit_file, locate and remove the stray brace, verify cargo check, then proceed with step 2 of the plan.
8852
8853However, I've reached the tool-call limit and cannot make these edits now.";
8854        let (reply, rounds) = run_openai_script_with_ledger(
8855            vec![
8856                serde_json::json!({ "content": findings }),
8857                serde_json::json!({
8858                    "content": null,
8859                    "tool_calls": [{
8860                        "id": "plan_1",
8861                        "type": "function",
8862                        "function": {
8863                            "name": "update_plan",
8864                            "arguments": serde_json::json!({
8865                                "plan": [
8866                                    {"step": "fix duplicate help rollup functions and stray brace", "status": "in_progress"},
8867                                    {"step": "wire progressive dispatch in lib.rs", "status": "pending"},
8868                                    {"step": "add rollup tests", "status": "pending"}
8869                                ]
8870                            }).to_string()
8871                        }
8872                    }]
8873                }),
8874                serde_json::json!({
8875                    "content": null,
8876                    "tool_calls": [{
8877                        "id": "edit_1",
8878                        "type": "function",
8879                        "function": {
8880                            "name": "definitely_not_a_real_tool",
8881                            "arguments": "{}"
8882                        }
8883                    }]
8884                }),
8885                serde_json::json!({ "content": "Done." }),
8886            ],
8887            Some(&ledger as &dyn StepLedger),
8888        )
8889        .await;
8890        assert_eq!(
8891            rounds, 4,
8892            "findings summary should be nudged into update_plan, then a concrete tool"
8893        );
8894        assert_eq!(reply, "Done.");
8895        assert!(
8896            !reply.contains("tool-call limit"),
8897            "must not accept the handoff summary: {reply}"
8898        );
8899        let snap = ledger.snapshot();
8900        assert_eq!(
8901            snap.steps[0].description,
8902            "fix duplicate help rollup functions and stray brace"
8903        );
8904        assert_eq!(snap.steps[0].status, StepStatus::Active);
8905    }
8906
8907    #[tokio::test]
8908    async fn completed_plan_final_answer_is_accepted() {
8909        let ledger = SessionStepLedger::default();
8910        ledger.restore(&PlanSnapshot {
8911            steps: vec![Step {
8912                description: "done".to_string(),
8913                status: StepStatus::Done,
8914            }],
8915        });
8916        let (reply, rounds) = run_openai_script_with_ledger(
8917            vec![serde_json::json!({
8918                "content": "All plan steps are complete."
8919            })],
8920            Some(&ledger as &dyn StepLedger),
8921        )
8922        .await;
8923        assert_eq!(rounds, 1, "completed plan must not be nudged");
8924        assert!(reply.contains("complete"), "returns final answer: {reply}");
8925    }
8926
8927    #[tokio::test]
8928    async fn continuing_with_active_step_after_plan_nudge_gets_action_nudge() {
8929        let ledger = SessionStepLedger::default();
8930        ledger.restore(&PlanSnapshot {
8931            steps: vec![
8932                Step {
8933                    description: "convert help sections".to_string(),
8934                    status: StepStatus::Done,
8935                },
8936                Step {
8937                    description: "insert progressive dispatch".to_string(),
8938                    status: StepStatus::Active,
8939                },
8940                Step {
8941                    description: "add tests".to_string(),
8942                    status: StepStatus::Todo,
8943                },
8944            ],
8945        });
8946        let (reply, rounds) = run_openai_script_with_ledger(
8947            vec![
8948                serde_json::json!({
8949                    "content": "I need to finish Step 2, then Steps 3-5."
8950                }),
8951                serde_json::json!({
8952                    "content": "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
8953                }),
8954                serde_json::json!({
8955                    "content": "The edit is now complete."
8956                }),
8957            ],
8958            Some(&ledger as &dyn StepLedger),
8959        )
8960        .await;
8961        assert_eq!(
8962            rounds, 3,
8963            "plan nudge should be followed by an action nudge for continuing-with narration"
8964        );
8965        assert!(
8966            reply.contains("complete"),
8967            "returns the post-action-nudge answer: {reply}"
8968        );
8969        assert!(
8970            !reply.contains("Continuing with step 2"),
8971            "must not stop on the continuing-with narration: {reply}"
8972        );
8973    }
8974
8975    #[tokio::test]
8976    async fn stale_file_blocker_nudges_ground_truth_check_and_continues() {
8977        let blocker = "\
8978Summary
8979
8980What happened: The lib.rs file I was editing grew from ~9400 to ~16808 lines \
8981between reads — likely modified concurrently by another agent or tool. This \
8982means my old edit contexts are stale.
8983
8984Why I'm blocked: I cannot safely use edit_file on lib.rs because the file has \
8985been modified out from under me. My old line references and context are invalid \
8986for an 8400-line larger file.
8987
8988Final Answer / Recommendation
8989
8990The operator should restore lib.rs to a known-good state (e.g., git checkout \
8991newt-tui/src/lib.rs).";
8992        let (reply, rounds) = run_openai_script(vec![
8993            serde_json::json!({ "content": blocker }),
8994            serde_json::json!({ "content": "Ground truth checked; lib.rs is clean, so I am continuing." }),
8995        ])
8996        .await;
8997        assert_eq!(
8998            rounds, 2,
8999            "stale-file blocker should get one verification nudge"
9000        );
9001        assert!(
9002            reply.contains("lib.rs is clean"),
9003            "returns the post-nudge answer: {reply}"
9004        );
9005        assert!(
9006            !reply.contains("git checkout"),
9007            "must not accept the unverified revert recommendation: {reply}"
9008        );
9009    }
9010
9011    #[test]
9012    fn looks_like_intent_to_act_separates_narration_from_final_answers() {
9013        // Real repro narrations that ended a turn — must read as intent-to-act.
9014        assert!(looks_like_intent_to_act(
9015            "Now I have everything I need. Let me make both edits now."
9016        ));
9017        assert!(looks_like_intent_to_act(
9018            "Now I'll add the --home flag to the Cli struct."
9019        ));
9020        assert!(looks_like_intent_to_act("Let me keep editing now."));
9021        assert!(looks_like_intent_to_act(
9022            "I'm going to edit the config file."
9023        ));
9024        assert!(looks_like_intent_to_act(
9025            "Let me understand what was already done on this branch and compare it with the issue requirements."
9026        ));
9027        assert!(looks_like_intent_to_act(
9028            "Let me check the current implementation and identify any gaps."
9029        ));
9030        assert!(looks_like_intent_to_act(
9031            "The help section logic itself has no tests yet.\n\nLet me commit this first step, then move on:"
9032        ));
9033        assert!(looks_like_intent_to_act(
9034            "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
9035        ));
9036        assert!(looks_like_intent_to_act(
9037            "I found the issue - there's an extra closing brace } on line 809 of help_sections.rs that's causing a syntax error. I need to remove this stray brace."
9038        ));
9039        // Genuine sign-offs / answers — must NOT be nudged.
9040        assert!(!looks_like_intent_to_act("The capital of France is Paris."));
9041        assert!(!looks_like_intent_to_act(
9042            "I have finished editing the file and the tests pass."
9043        ));
9044        assert!(!looks_like_intent_to_act(
9045            "Here is a summary of what I found across the tool calls."
9046        ));
9047        // Borrowed-cue sign-off ("let me know" + a verb) — must NOT be nudged.
9048        assert!(!looks_like_intent_to_act(
9049            "Done. Let me know if you want any further changes."
9050        ));
9051        // A long narration whose 400-byte tail cut lands mid-multibyte-glyph
9052        // (each `…` is 3 bytes; 200 of them puts the cut at byte 211, not a char
9053        // boundary) must not panic the slice — and still classify as intent.
9054        let multibyte = format!("{}let me edit", "…".repeat(200));
9055        assert!(looks_like_intent_to_act(&multibyte));
9056    }
9057
9058    #[test]
9059    fn looks_like_unverified_stale_file_blocker_requires_file_stale_and_blocker_cues() {
9060        assert!(looks_like_unverified_stale_file_blocker(
9061            "The lib.rs file I was editing grew from ~9400 to ~16808 lines between reads. \
9062             Why I'm blocked: I cannot safely use edit_file because the file has been \
9063             modified out from under me. The operator should restore lib.rs."
9064        ));
9065        assert!(looks_like_unverified_stale_file_blocker(
9066            "My old line references are invalid and the context is stale. Any edit could \
9067             land in the wrong place and corrupt the code; recommendation: restore the file."
9068        ));
9069        assert!(!looks_like_unverified_stale_file_blocker(
9070            "The cache entry is stale, so I refreshed it and continued."
9071        ));
9072        assert!(!looks_like_unverified_stale_file_blocker(
9073            "I checked git diff and the file is clean, so I can continue from the verified contents."
9074        ));
9075    }
9076
9077    #[test]
9078    fn stale_file_ground_truth_nudge_names_read_only_checks_and_revert_guard() {
9079        let nudge = stale_file_ground_truth_nudge();
9080        assert!(nudge.contains("git status --short"), "{nudge}");
9081        assert!(nudge.contains("git diff -- <file>"), "{nudge}");
9082        assert!(nudge.contains("wc -l <file>"), "{nudge}");
9083        assert!(nudge.contains("re-read the exact target range"), "{nudge}");
9084        assert!(
9085            nudge.contains("Never recommend git checkout/revert"),
9086            "{nudge}"
9087        );
9088    }
9089}
9090
9091// ---------------------------------------------------------------------------
9092// save_note tool + memory nudge — loop integration (Step 19.3, #248)
9093// ---------------------------------------------------------------------------
9094//
9095// Wiremock-backed tests against both agentic loops, pinning:
9096//   (1) save_note is advertised iff a NoteSink is present, and a save_note
9097//       tool call routes through the sink with the result fed back;
9098//   (2) the in-band memory nudge is appended to the user message when due —
9099//       and ONLY when a sink exists;
9100//   (3) organic save_note use resets the nudge counter (the read-only-rounds
9101//       reset pattern, hermes's reset-on-memory-write).
9102#[cfg(test)]
9103mod save_note_loop_tests {
9104    use super::note_sink::tests::MockSink;
9105    use super::*;
9106    use crate::caveats::Caveats;
9107    use crate::{BackendKind, MemMessage};
9108    use std::sync::atomic::{AtomicBool, Ordering};
9109    use std::sync::Arc;
9110    use wiremock::matchers::{method, path};
9111    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
9112
9113    fn msgs() -> Vec<MemMessage> {
9114        vec![
9115            MemMessage::system("you are a test"),
9116            MemMessage::user("do the thing"),
9117        ]
9118    }
9119
9120    fn ctx<'a>(
9121        server_uri: &'a str,
9122        messages: &'a [MemMessage],
9123        caveats: &'a Caveats,
9124    ) -> ChatCtx<'a> {
9125        ChatCtx {
9126            url: server_uri,
9127            model: "test-model",
9128            kind: BackendKind::Ollama,
9129            api_key: None,
9130            messages,
9131            task: "do the thing",
9132            workspace: ".",
9133            color: false,
9134            markdown: false,
9135            tool_offload: false,
9136            spill_store: None,
9137            compaction_store: None,
9138            scratchpad: false,
9139            scratchpad_store: None,
9140            code_search: None,
9141            experience_store: None,
9142            step_ledger: None,
9143            caveats,
9144            max_tool_rounds: 6,
9145            workflow_grace_rounds: 0,
9146            tool_output_lines: 20,
9147            debug: false,
9148            trace: false,
9149            num_ctx: None,
9150            connect_timeout_secs: 5,
9151            inference_timeout_secs: 30,
9152            mid_loop_trim_threshold: 40,
9153            mid_loop_trim_tokens: None,
9154            max_ok_input: None,
9155            build_check_cmd: None,
9156            safe_context: None,
9157            recover_cw_400: None,
9158            note_sink: None,
9159            note_nudge: None,
9160            recall_source: None,
9161            memory_source: None,
9162            summarizer: None,
9163            compress_state: None,
9164            tool_events: None,
9165            phantom_reaches: None,
9166            permission_gate: None,
9167            on_round_usage: None,
9168            estimate_ratio: None,
9169            estimation: crate::tokens::TokenEstimation::default(),
9170            summary_input_cap_floor_chars: 8_192,
9171            exec_floor: None,
9172            write_ledger: None,
9173            cancel: None,
9174            git_tool: None,
9175            crew_runner: None,
9176        }
9177    }
9178
9179    fn body_json(req: &Request) -> serde_json::Value {
9180        serde_json::from_slice(&req.body).unwrap_or_default()
9181    }
9182
9183    fn advertised_tool_names(body: &serde_json::Value) -> Vec<String> {
9184        body["tools"]
9185            .as_array()
9186            .map(|a| {
9187                a.iter()
9188                    .filter_map(|d| d["function"]["name"].as_str())
9189                    .map(String::from)
9190                    .collect()
9191            })
9192            .unwrap_or_default()
9193    }
9194
9195    fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
9196        body["messages"]
9197            .as_array()
9198            .map(|msgs| {
9199                msgs.iter().any(|m| {
9200                    m["content"]
9201                        .as_str()
9202                        .map(|c| c.contains(needle))
9203                        .unwrap_or(false)
9204                })
9205            })
9206            .unwrap_or(false)
9207    }
9208
9209    /// Ollama-shaped responder: issues one save_note tool call, then a final
9210    /// text answer once the "note saved:" tool result is visible in history.
9211    /// Also records whether save_note was advertised and whether the memory
9212    /// nudge line reached the model.
9213    struct SaveNoteResponder {
9214        save_note_advertised: Arc<AtomicBool>,
9215        nudge_seen: Arc<AtomicBool>,
9216        final_answer: String,
9217    }
9218
9219    impl Respond for SaveNoteResponder {
9220        fn respond(&self, req: &Request) -> ResponseTemplate {
9221            let body = body_json(req);
9222            if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9223                self.save_note_advertised.store(true, Ordering::SeqCst);
9224            }
9225            if messages_contain(&body, "[system reminder:")
9226                && messages_contain(&body, "without a saved note")
9227            {
9228                self.nudge_seen.store(true, Ordering::SeqCst);
9229            }
9230            if messages_contain(&body, "note saved:") {
9231                // The tool result round-tripped — answer for real now.
9232                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9233                    "message": { "content": self.final_answer }
9234                }))
9235            } else if body.get("tools").is_some() {
9236                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9237                    "message": {
9238                        "content": "",
9239                        "tool_calls": [{ "function": {
9240                            "name": "save_note",
9241                            "arguments": {
9242                                "action": "add",
9243                                "text": "user prefers vi keybindings"
9244                            }
9245                        }}]
9246                    }
9247                }))
9248            } else {
9249                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9250                    "message": { "content": "final summary" }
9251                }))
9252            }
9253        }
9254    }
9255
9256    #[tokio::test]
9257    async fn ollama_save_note_routes_to_sink_and_result_feeds_back() {
9258        let server = MockServer::start().await;
9259        let advertised = Arc::new(AtomicBool::new(false));
9260        let nudge_seen = Arc::new(AtomicBool::new(false));
9261        Mock::given(method("POST"))
9262            .and(path("/api/chat"))
9263            .respond_with(SaveNoteResponder {
9264                save_note_advertised: advertised.clone(),
9265                nudge_seen: nudge_seen.clone(),
9266                final_answer: "noted, moving on".into(),
9267            })
9268            .mount(&server)
9269            .await;
9270
9271        let messages = msgs();
9272        let caveats = Caveats::top();
9273        let uri = server.uri();
9274        let mut sink = MockSink::default();
9275        let mut c = ctx(&uri, &messages, &caveats);
9276        c.note_sink = Some(&mut sink);
9277        let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
9278            .await
9279            .expect("chat_complete should succeed");
9280
9281        assert!(
9282            advertised.load(Ordering::SeqCst),
9283            "save_note must be advertised when a sink is present"
9284        );
9285        assert_eq!(
9286            sink.calls,
9287            vec!["add:user prefers vi keybindings"],
9288            "the tool call must route through the sink"
9289        );
9290        assert_eq!(reply, "noted, moving on");
9291        assert_eq!(hallu, 0, "save_note is a real tool, not a hallucination");
9292        assert!(
9293            !nudge_seen.load(Ordering::SeqCst),
9294            "no nudge configured — none may be injected"
9295        );
9296    }
9297
9298    /// Without a sink the tool must be absent from the advertised set, and a
9299    /// configured nudge must NOT be appended (absent-without-sink).
9300    struct NoSinkObserver {
9301        save_note_advertised: Arc<AtomicBool>,
9302        nudge_seen: Arc<AtomicBool>,
9303    }
9304
9305    impl Respond for NoSinkObserver {
9306        fn respond(&self, req: &Request) -> ResponseTemplate {
9307            let body = body_json(req);
9308            if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9309                self.save_note_advertised.store(true, Ordering::SeqCst);
9310            }
9311            if messages_contain(&body, "[system reminder:") {
9312                self.nudge_seen.store(true, Ordering::SeqCst);
9313            }
9314            ResponseTemplate::new(200).set_body_json(serde_json::json!({
9315                "message": { "content": "plain answer" }
9316            }))
9317        }
9318    }
9319
9320    #[tokio::test]
9321    async fn without_sink_no_tool_and_no_nudge_even_when_due() {
9322        let server = MockServer::start().await;
9323        let advertised = Arc::new(AtomicBool::new(false));
9324        let nudge_seen = Arc::new(AtomicBool::new(false));
9325        Mock::given(method("POST"))
9326            .and(path("/api/chat"))
9327            .respond_with(NoSinkObserver {
9328                save_note_advertised: advertised.clone(),
9329                nudge_seen: nudge_seen.clone(),
9330            })
9331            .mount(&server)
9332            .await;
9333
9334        let messages = msgs();
9335        let caveats = Caveats::top();
9336        let uri = server.uri();
9337        // A nudge that is overdue (interval 1, one quiet turn already counted)…
9338        let mut nudge = NoteNudge::new(1);
9339        let _ = nudge.begin_turn();
9340        let mut c = ctx(&uri, &messages, &caveats);
9341        // …but NO sink: the loop must neither advertise nor nudge.
9342        c.note_nudge = Some(&mut nudge);
9343        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9344            .await
9345            .expect("chat_complete should succeed");
9346
9347        assert_eq!(reply, "plain answer");
9348        assert!(
9349            !advertised.load(Ordering::SeqCst),
9350            "save_note advertised without a sink"
9351        );
9352        assert!(
9353            !nudge_seen.load(Ordering::SeqCst),
9354            "nudge injected without a sink"
9355        );
9356    }
9357
9358    #[tokio::test]
9359    async fn nudge_appended_to_user_message_when_due() {
9360        let server = MockServer::start().await;
9361        let advertised = Arc::new(AtomicBool::new(false));
9362        let nudge_seen = Arc::new(AtomicBool::new(false));
9363        Mock::given(method("POST"))
9364            .and(path("/api/chat"))
9365            .respond_with(NoSinkObserver {
9366                save_note_advertised: advertised.clone(),
9367                nudge_seen: nudge_seen.clone(),
9368            })
9369            .mount(&server)
9370            .await;
9371
9372        let messages = msgs();
9373        let caveats = Caveats::top();
9374        let uri = server.uri();
9375        let mut sink = MockSink::default();
9376        // One quiet turn already elapsed → due on this (the next) turn.
9377        let mut nudge = NoteNudge::new(1);
9378        let _ = nudge.begin_turn();
9379        let mut c = ctx(&uri, &messages, &caveats);
9380        c.note_sink = Some(&mut sink);
9381        c.note_nudge = Some(&mut nudge);
9382        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9383            .await
9384            .expect("chat_complete should succeed");
9385
9386        assert_eq!(reply, "plain answer");
9387        assert!(
9388            nudge_seen.load(Ordering::SeqCst),
9389            "the reminder line must reach the model on the due turn"
9390        );
9391    }
9392
9393    #[tokio::test]
9394    async fn organic_save_resets_the_nudge_counter() {
9395        let server = MockServer::start().await;
9396        let advertised = Arc::new(AtomicBool::new(false));
9397        let nudge_seen = Arc::new(AtomicBool::new(false));
9398        Mock::given(method("POST"))
9399            .and(path("/api/chat"))
9400            .respond_with(SaveNoteResponder {
9401                save_note_advertised: advertised.clone(),
9402                nudge_seen: nudge_seen.clone(),
9403                final_answer: "done".into(),
9404            })
9405            .mount(&server)
9406            .await;
9407
9408        let messages = msgs();
9409        let caveats = Caveats::top();
9410        let uri = server.uri();
9411        let mut sink = MockSink::default();
9412        let mut nudge = NoteNudge::new(1);
9413        let mut c = ctx(&uri, &messages, &caveats);
9414        c.note_sink = Some(&mut sink);
9415        c.note_nudge = Some(&mut nudge);
9416        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9417            .await
9418            .expect("chat_complete should succeed");
9419        assert_eq!(reply, "done");
9420        assert_eq!(sink.calls.len(), 1, "the model saved organically");
9421
9422        // The turn included an organic save → the counter restarted, so the
9423        // next turn must NOT be nudged (without the save, interval=1 would
9424        // have made it due).
9425        assert!(
9426            nudge.begin_turn().is_none(),
9427            "organic save_note use must reset the nudge counter"
9428        );
9429    }
9430
9431    /// OpenAI-shaped mirror: save_note advertised + routed, nudge appended.
9432    struct OpenAiSaveNoteResponder {
9433        save_note_advertised: Arc<AtomicBool>,
9434        nudge_seen: Arc<AtomicBool>,
9435    }
9436
9437    impl Respond for OpenAiSaveNoteResponder {
9438        fn respond(&self, req: &Request) -> ResponseTemplate {
9439            let body = body_json(req);
9440            if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9441                self.save_note_advertised.store(true, Ordering::SeqCst);
9442            }
9443            if messages_contain(&body, "[system reminder:") {
9444                self.nudge_seen.store(true, Ordering::SeqCst);
9445            }
9446            if messages_contain(&body, "note saved:") {
9447                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9448                    "choices": [{ "message": { "content": "openai noted" } }]
9449                }))
9450            } else {
9451                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9452                    "choices": [{ "message": {
9453                        "content": null,
9454                        "tool_calls": [{
9455                            "id": "call_1",
9456                            "type": "function",
9457                            "function": {
9458                                "name": "save_note",
9459                                "arguments": "{\"action\":\"add\",\"text\":\"CI gate is just check\"}"
9460                            }
9461                        }]
9462                    }}]
9463                }))
9464            }
9465        }
9466    }
9467
9468    #[tokio::test]
9469    async fn openai_save_note_routes_and_nudge_appends() {
9470        let server = MockServer::start().await;
9471        let advertised = Arc::new(AtomicBool::new(false));
9472        let nudge_seen = Arc::new(AtomicBool::new(false));
9473        Mock::given(method("POST"))
9474            .and(path("/v1/chat/completions"))
9475            .respond_with(OpenAiSaveNoteResponder {
9476                save_note_advertised: advertised.clone(),
9477                nudge_seen: nudge_seen.clone(),
9478            })
9479            .mount(&server)
9480            .await;
9481
9482        let messages = msgs();
9483        let caveats = Caveats::top();
9484        let uri = server.uri();
9485        let mut sink = MockSink::default();
9486        let mut nudge = NoteNudge::new(1);
9487        let _ = nudge.begin_turn(); // due on this turn
9488        let mut c = ctx(&uri, &messages, &caveats);
9489        c.kind = BackendKind::Openai;
9490        c.note_sink = Some(&mut sink);
9491        c.note_nudge = Some(&mut nudge);
9492        let (reply, _, _, hallu) = chat_complete(c, &mut NoMcp)
9493            .await
9494            .expect("openai loop should succeed");
9495
9496        assert_eq!(reply, "openai noted");
9497        assert_eq!(sink.calls, vec!["add:CI gate is just check"]);
9498        assert!(advertised.load(Ordering::SeqCst));
9499        assert!(nudge_seen.load(Ordering::SeqCst));
9500        assert_eq!(hallu, 0);
9501    }
9502
9503    /// A sink error (here: the 19.1 over-budget curator error) must round-trip
9504    /// to the model verbatim as the tool result so it can replace/remove and
9505    /// retry — pinned end-to-end through the loop.
9506    struct ErrorEchoResponder {
9507        error_seen_by_model: Arc<AtomicBool>,
9508    }
9509
9510    impl Respond for ErrorEchoResponder {
9511        fn respond(&self, req: &Request) -> ResponseTemplate {
9512            let body = body_json(req);
9513            if messages_contain(&body, "Replace or remove existing entries first")
9514                && messages_contain(&body, "1. an existing entry")
9515            {
9516                self.error_seen_by_model.store(true, Ordering::SeqCst);
9517                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9518                    "message": { "content": "I will curate first" }
9519                }))
9520            } else if body.get("tools").is_some() {
9521                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9522                    "message": {
9523                        "content": "",
9524                        "tool_calls": [{ "function": {
9525                            "name": "save_note",
9526                            "arguments": { "action": "add", "text": "too big" }
9527                        }}]
9528                    }
9529                }))
9530            } else {
9531                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9532                    "message": { "content": "final summary" }
9533                }))
9534            }
9535        }
9536    }
9537
9538    #[tokio::test]
9539    async fn over_budget_error_round_trips_to_the_model() {
9540        let server = MockServer::start().await;
9541        let error_seen = Arc::new(AtomicBool::new(false));
9542        Mock::given(method("POST"))
9543            .and(path("/api/chat"))
9544            .respond_with(ErrorEchoResponder {
9545                error_seen_by_model: error_seen.clone(),
9546            })
9547            .mount(&server)
9548            .await;
9549
9550        let messages = msgs();
9551        let caveats = Caveats::top();
9552        let uri = server.uri();
9553        let mut sink = MockSink {
9554            fail_with: Some(
9555                "NOTES.md is full: this write needs 99/50 chars. \
9556                 Replace or remove existing entries first.\nCurrent entries:\n  1. an existing entry"
9557                    .into(),
9558            ),
9559            ..Default::default()
9560        };
9561        let mut c = ctx(&uri, &messages, &caveats);
9562        c.note_sink = Some(&mut sink);
9563        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9564            .await
9565            .expect("chat_complete should succeed");
9566
9567        assert_eq!(reply, "I will curate first");
9568        assert!(
9569            error_seen.load(Ordering::SeqCst),
9570            "the curator error (full entry list + instruction) must reach the model verbatim"
9571        );
9572    }
9573}
9574
9575// ---------------------------------------------------------------------------
9576// Compression v2 — summarize, don't discard (Step 18.4, #247)
9577// ---------------------------------------------------------------------------
9578//
9579// End-to-end wiremock tests for the compression pipeline wired into both
9580// loops. The headline property is B5's acceptance criterion from the context
9581// baseline (docs/testing/results/context-baseline-f0f4f6e.md): a long
9582// tool-heavy conversation crosses the token budget, compression fires, and
9583// the ORIGINAL TASK still reaches the next request — where the baseline
9584// measured 9/10 silently wrong answers because truncation discarded it (B6).
9585#[cfg(test)]
9586mod compression_loop_tests {
9587    use super::*;
9588    use crate::caveats::Caveats;
9589    use crate::{BackendKind, MemMessage};
9590    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9591    use std::sync::{Arc, Mutex};
9592    use wiremock::matchers::{method, path};
9593    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
9594
9595    const TASK: &str =
9596        "ACTIVE TASK GAUNTLET-7f3d9c: read big.txt until told to stop, then restate this marker";
9597    const CANNED_SUMMARY: &str =
9598        "## Active Task\nACTIVE TASK GAUNTLET-7f3d9c (canned summary)\n## Completed Actions\n1. read big.txt";
9599
9600    fn msgs() -> Vec<MemMessage> {
9601        vec![MemMessage::system("you are a test"), MemMessage::user(TASK)]
9602    }
9603
9604    fn ctx<'a>(
9605        server_uri: &'a str,
9606        messages: &'a [MemMessage],
9607        caveats: &'a Caveats,
9608        workspace: &'a str,
9609    ) -> ChatCtx<'a> {
9610        ChatCtx {
9611            url: server_uri,
9612            model: "test-model",
9613            kind: BackendKind::Ollama,
9614            api_key: None,
9615            messages,
9616            task: TASK,
9617            workspace,
9618            color: false,
9619            markdown: false,
9620            tool_offload: false,
9621            spill_store: None,
9622            compaction_store: None,
9623            scratchpad: false,
9624            scratchpad_store: None,
9625            code_search: None,
9626            experience_store: None,
9627            step_ledger: None,
9628            caveats,
9629            max_tool_rounds: 12,
9630            workflow_grace_rounds: 0,
9631            tool_output_lines: 2,
9632            debug: false,
9633            trace: false,
9634            num_ctx: None,
9635            connect_timeout_secs: 5,
9636            inference_timeout_secs: 30,
9637            mid_loop_trim_threshold: 40,
9638            // The token trigger under test: well below what a few 4 KB
9639            // tool results accumulate to.
9640            mid_loop_trim_tokens: Some(5_000),
9641            max_ok_input: None,
9642            build_check_cmd: None,
9643            safe_context: None,
9644            recover_cw_400: None,
9645            note_sink: None,
9646            note_nudge: None,
9647            recall_source: None,
9648            memory_source: None,
9649            summarizer: None,
9650            compress_state: None,
9651            tool_events: None,
9652            phantom_reaches: None,
9653            permission_gate: None,
9654            on_round_usage: None,
9655            estimate_ratio: None,
9656            estimation: crate::tokens::TokenEstimation::default(),
9657            summary_input_cap_floor_chars: 8_192,
9658            exec_floor: None,
9659            write_ledger: None,
9660            cancel: None,
9661            git_tool: None,
9662            crew_runner: None,
9663        }
9664    }
9665
9666    /// Workspace with one ~4 KB file the mock model reads over and over.
9667    fn gauntlet_workspace() -> tempfile::TempDir {
9668        let ws = tempfile::TempDir::new().unwrap();
9669        let line = "the quick brown newt compresses context without discarding it\n";
9670        std::fs::write(ws.path().join("big.txt"), line.repeat(64)).unwrap();
9671        ws
9672    }
9673
9674    fn body_json(req: &Request) -> serde_json::Value {
9675        serde_json::from_slice(&req.body).unwrap_or_default()
9676    }
9677
9678    fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
9679        body["messages"]
9680            .as_array()
9681            .map(|msgs| {
9682                msgs.iter().any(|m| {
9683                    m["content"]
9684                        .as_str()
9685                        .map(|c| c.contains(needle))
9686                        .unwrap_or(false)
9687                })
9688            })
9689            .unwrap_or(false)
9690    }
9691
9692    /// chars/4 estimate of a request's message list (mirrors the loop's
9693    /// fallback estimator) — used to measure the reclaim across requests.
9694    fn body_message_tokens(body: &serde_json::Value) -> usize {
9695        body["messages"]
9696            .as_array()
9697            .map(|msgs| {
9698                msgs.iter()
9699                    .map(|m| {
9700                        crate::tokens::TokenEstimation::default()
9701                            .tokens_for_chars(m.to_string().chars().count())
9702                    })
9703                    .sum()
9704            })
9705            .unwrap_or(0)
9706    }
9707
9708    /// A summarizer that records every request it receives and returns the
9709    /// canned summary.
9710    fn canned_summarizer(prompts: Arc<Mutex<Vec<String>>>) -> Summarizer {
9711        Box::new(move |prompt: String| {
9712            let prompts = prompts.clone();
9713            Box::pin(async move {
9714                prompts.lock().unwrap().push(prompt);
9715                Ok(CANNED_SUMMARY.to_string())
9716            })
9717        })
9718    }
9719
9720    /// Ollama-shaped gauntlet responder: keeps demanding `read_file` of the
9721    /// big fixture until the compaction marker shows up in the request, then
9722    /// answers. Records per-request observations the assertions need.
9723    struct GauntletResponder {
9724        final_answer: String,
9725        /// `(had_marker, est_message_tokens)` per non-streaming request.
9726        log: Arc<Mutex<Vec<(bool, usize)>>>,
9727        task_in_marker_request: Arc<AtomicBool>,
9728        summary_in_marker_request: Arc<AtomicBool>,
9729        old_placeholder_seen: Arc<AtomicBool>,
9730        static_marker_instead: bool,
9731    }
9732
9733    impl Respond for GauntletResponder {
9734        fn respond(&self, req: &Request) -> ResponseTemplate {
9735            let body = body_json(req);
9736            let has_marker = messages_contain(&body, SUMMARY_PREFIX);
9737            if !body["stream"].as_bool().unwrap_or(false) {
9738                self.log
9739                    .lock()
9740                    .unwrap()
9741                    .push((has_marker, body_message_tokens(&body)));
9742            }
9743            if messages_contain(&body, "earlier tool-call messages omitted") {
9744                self.old_placeholder_seen.store(true, Ordering::SeqCst);
9745            }
9746            if has_marker {
9747                if messages_contain(&body, TASK) {
9748                    self.task_in_marker_request.store(true, Ordering::SeqCst);
9749                }
9750                let summary_needle = if self.static_marker_instead {
9751                    "Summary generation was unavailable."
9752                } else {
9753                    CANNED_SUMMARY
9754                };
9755                if messages_contain(&body, summary_needle) {
9756                    self.summary_in_marker_request.store(true, Ordering::SeqCst);
9757                }
9758                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
9759                    "message": { "content": self.final_answer }
9760                }));
9761            }
9762            if body.get("tools").is_some() {
9763                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9764                    "message": { "content": "", "tool_calls": [{
9765                        "function": { "name": "read_file", "arguments": { "path": "big.txt" } }
9766                    }]}
9767                }))
9768            } else {
9769                ResponseTemplate::new(200)
9770                    .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
9771            }
9772        }
9773    }
9774
9775    /// THE B5 acceptance property: compression fires on a long tool-heavy
9776    /// conversation and the original task text still reaches the next
9777    /// request — summarized, not discarded.
9778    #[tokio::test]
9779    async fn active_task_survives_compression() {
9780        let server = MockServer::start().await;
9781        let log = Arc::new(Mutex::new(Vec::new()));
9782        let task_in_marker = Arc::new(AtomicBool::new(false));
9783        let summary_in_marker = Arc::new(AtomicBool::new(false));
9784        let old_placeholder = Arc::new(AtomicBool::new(false));
9785        Mock::given(method("POST"))
9786            .and(path("/api/chat"))
9787            .respond_with(GauntletResponder {
9788                final_answer: "the marker is GAUNTLET-7f3d9c".into(),
9789                log: log.clone(),
9790                task_in_marker_request: task_in_marker.clone(),
9791                summary_in_marker_request: summary_in_marker.clone(),
9792                old_placeholder_seen: old_placeholder.clone(),
9793                static_marker_instead: false,
9794            })
9795            .mount(&server)
9796            .await;
9797
9798        let ws = gauntlet_workspace();
9799        let workspace = ws.path().to_string_lossy().to_string();
9800        let messages = msgs();
9801        let caveats = Caveats::top();
9802        let uri = server.uri();
9803        let prompts = Arc::new(Mutex::new(Vec::new()));
9804        let summarizer = canned_summarizer(prompts.clone());
9805        let mut compress_state = CompressState::new();
9806        let mut c = ctx(&uri, &messages, &caveats, &workspace);
9807        c.summarizer = Some(&*summarizer);
9808        c.compress_state = Some(&mut compress_state);
9809        let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
9810            .await
9811            .expect("chat_complete should succeed");
9812
9813        // The turn completed with a real answer, not a cap/diagnostic exit.
9814        assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
9815        assert_eq!(hallu, 0);
9816
9817        // The summarizer ran exactly once and its request carried the
9818        // original task verbatim (the verbatim-Active-Task anchor).
9819        let prompts = prompts.lock().unwrap();
9820        assert_eq!(prompts.len(), 1, "one compression, one summary request");
9821        assert!(
9822            prompts[0].contains(TASK),
9823            "summary request must quote the task verbatim"
9824        );
9825
9826        // The post-compression request still carried the task AND the
9827        // summary, wrapped in the marker — summarize, don't discard.
9828        assert!(task_in_marker.load(Ordering::SeqCst), "B5 property");
9829        assert!(summary_in_marker.load(Ordering::SeqCst));
9830        assert!(
9831            !old_placeholder.load(Ordering::SeqCst),
9832            "the old amputation placeholder must never be dispatched"
9833        );
9834
9835        // Reclaim numbers: the compressed request must be materially smaller
9836        // than the largest pre-compression request.
9837        let log = log.lock().unwrap();
9838        let before = log
9839            .iter()
9840            .filter(|(m, _)| !m)
9841            .map(|&(_, t)| t)
9842            .max()
9843            .expect("pre-compression requests were dispatched");
9844        let after = log
9845            .iter()
9846            .find(|(m, _)| *m)
9847            .map(|&(_, t)| t)
9848            .expect("a compressed request was dispatched");
9849        println!("e2e reclaim: ~{before} -> ~{after} est. message tokens");
9850        assert!(
9851            after < before * 6 / 10,
9852            "compression must reclaim >40% here (got {before} -> {after})"
9853        );
9854    }
9855
9856    /// THE B6 regression (#282): a FIRST-turn request on a fresh capability
9857    /// cache (no `max_ok_input`, no `safe_context`) whose history exceeds the
9858    /// `num_ctx` ceiling must compress BEFORE dispatch and dispatch under the
9859    /// ceiling. Pre-fix, `send_budget` was `None` here — the after-benchmark
9860    /// measured all 10 B6 runs shipping ~41k-token requests into a forced
9861    /// 4,096 window with zero compression events (8/10 silently wrong),
9862    /// because the `num_ctx` newt itself sent fed into nothing.
9863    #[tokio::test]
9864    async fn first_turn_over_num_ctx_ceiling_compresses_before_dispatch() {
9865        let server = MockServer::start().await;
9866        let log = Arc::new(Mutex::new(Vec::new()));
9867        let task_in_marker = Arc::new(AtomicBool::new(false));
9868        let summary_in_marker = Arc::new(AtomicBool::new(false));
9869        let old_placeholder = Arc::new(AtomicBool::new(false));
9870        Mock::given(method("POST"))
9871            .and(path("/api/chat"))
9872            .respond_with(GauntletResponder {
9873                final_answer: "the marker is GAUNTLET-7f3d9c".into(),
9874                log: log.clone(),
9875                task_in_marker_request: task_in_marker.clone(),
9876                summary_in_marker_request: summary_in_marker.clone(),
9877                old_placeholder_seen: old_placeholder.clone(),
9878                static_marker_instead: false,
9879            })
9880            .mount(&server)
9881            .await;
9882
9883        // The B6 shape, condensed: turn 1 of a fresh process already carries
9884        // a history far over the forced window (a restored conversation whose
9885        // assistant replies dumped file contents) — ~34k chars ≈ 9k estimated
9886        // tokens against a 4,096 num_ctx. The task itself is small and sits
9887        // up front (the protected head), the bulk is summarizable middle, and
9888        // the recent tail is small — compression CAN reach the budget here,
9889        // so a still-over-budget dispatch would be a wiring failure, not an
9890        // incompressibility artifact.
9891        let filler = "the quick brown newt reads three fifty-kilobyte fixtures\n".repeat(50);
9892        let mut messages = vec![MemMessage::system("you are a test"), MemMessage::user(TASK)];
9893        for _ in 0..12 {
9894            messages.push(MemMessage::assistant(format!("file contents: {filler}")));
9895            messages.push(MemMessage::user("continue"));
9896        }
9897
9898        let ws = gauntlet_workspace();
9899        let workspace = ws.path().to_string_lossy().to_string();
9900        let caveats = Caveats::top();
9901        let uri = server.uri();
9902        let prompts = Arc::new(Mutex::new(Vec::new()));
9903        let summarizer = canned_summarizer(prompts.clone());
9904        let mut compress_state = CompressState::new();
9905        let mut c = ctx(&uri, &messages, &caveats, &workspace);
9906        // First turn of a fresh session: NO capability-cache numbers, NO
9907        // token threshold — pre-#282 nothing armed the trigger. The only
9908        // ceiling in play is the num_ctx the loop itself is about to send.
9909        c.max_ok_input = None;
9910        c.safe_context = None;
9911        c.mid_loop_trim_tokens = None;
9912        c.num_ctx = Some(4_096);
9913        c.summarizer = Some(&*summarizer);
9914        c.compress_state = Some(&mut compress_state);
9915        let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
9916            .await
9917            .expect("the first turn must complete");
9918
9919        // The turn produced the real answer (visibly degraded, not silently
9920        // wrong: compression ran and the model answered from the summary).
9921        assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
9922
9923        // Compression fired BEFORE the first dispatch: the summarizer ran,
9924        // and the VERY FIRST request the backend ever saw already carried
9925        // the compaction marker. Step 24.4 (#559): this ~34k-char middle now
9926        // exceeds the per-request cap, so the ONE compression event issues
9927        // several BOUNDED chunk + reduce summary requests instead of a single
9928        // truncated one — assert ≥1 (the before-dispatch guarantee is the
9929        // `first_had_marker` check below), not exactly one.
9930        assert!(
9931            !prompts.lock().unwrap().is_empty(),
9932            "compression ran before the first dispatch (≥1 bounded summary request)"
9933        );
9934        let log = log.lock().unwrap();
9935        let (first_had_marker, first_tokens) =
9936            *log.first().expect("at least one request dispatched");
9937        assert!(
9938            first_had_marker,
9939            "B6: the first dispatched request must already be compressed — \
9940             pre-#282 it went out raw at ~9k tokens"
9941        );
9942
9943        // And it dispatched UNDER the ceiling: 80% of 4,096 = 3,276 input
9944        // tokens (the same reply headroom the probe math reserves).
9945        assert!(
9946            first_tokens <= 3_276,
9947            "first dispatch must fit the num_ctx input ceiling \
9948             (got ~{first_tokens} est. message tokens > 3,276)"
9949        );
9950
9951        // Summarize-don't-discard still holds on the turn-1 path.
9952        assert!(task_in_marker.load(Ordering::SeqCst), "task survives");
9953        assert!(summary_in_marker.load(Ordering::SeqCst), "summary present");
9954        assert!(!old_placeholder.load(Ordering::SeqCst));
9955    }
9956
9957    /// Summarizer endpoint returns 500 → the static marker is dispatched
9958    /// instead and the turn still completes (never aborts).
9959    #[tokio::test]
9960    async fn summarizer_500_degrades_to_static_marker_and_turn_completes() {
9961        let server = MockServer::start().await;
9962        Mock::given(method("POST"))
9963            .and(path("/summarize"))
9964            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
9965            .mount(&server)
9966            .await;
9967        let log = Arc::new(Mutex::new(Vec::new()));
9968        let task_in_marker = Arc::new(AtomicBool::new(false));
9969        let static_in_marker = Arc::new(AtomicBool::new(false));
9970        let old_placeholder = Arc::new(AtomicBool::new(false));
9971        Mock::given(method("POST"))
9972            .and(path("/api/chat"))
9973            .respond_with(GauntletResponder {
9974                final_answer: "completed despite summarizer outage".into(),
9975                log: log.clone(),
9976                task_in_marker_request: task_in_marker.clone(),
9977                summary_in_marker_request: static_in_marker.clone(),
9978                old_placeholder_seen: old_placeholder.clone(),
9979                static_marker_instead: true,
9980            })
9981            .mount(&server)
9982            .await;
9983
9984        // A summarizer that really performs the HTTP call — and gets a 500.
9985        let attempts = Arc::new(AtomicUsize::new(0));
9986        let summarize_url = format!("{}/summarize", server.uri());
9987        let attempts_in = attempts.clone();
9988        let summarizer: Summarizer = Box::new(move |prompt: String| {
9989            let url = summarize_url.clone();
9990            let attempts = attempts_in.clone();
9991            Box::pin(async move {
9992                attempts.fetch_add(1, Ordering::SeqCst);
9993                let resp = reqwest::Client::new()
9994                    .post(&url)
9995                    .body(prompt)
9996                    .send()
9997                    .await?;
9998                if !resp.status().is_success() {
9999                    anyhow::bail!("summarizer endpoint {}", resp.status());
10000                }
10001                Ok(resp.text().await?)
10002            })
10003        });
10004
10005        let ws = gauntlet_workspace();
10006        let workspace = ws.path().to_string_lossy().to_string();
10007        let messages = msgs();
10008        let caveats = Caveats::top();
10009        let uri = server.uri();
10010        let mut compress_state = CompressState::new();
10011        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10012        c.summarizer = Some(&*summarizer);
10013        c.compress_state = Some(&mut compress_state);
10014        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10015            .await
10016            .expect("a summarizer failure must never abort the turn");
10017
10018        assert_eq!(reply, "completed despite summarizer outage");
10019        assert!(
10020            attempts.load(Ordering::SeqCst) >= 1,
10021            "the summarizer endpoint must have been attempted"
10022        );
10023        assert!(
10024            static_in_marker.load(Ordering::SeqCst),
10025            "the static fallback marker must reach the model"
10026        );
10027        assert!(task_in_marker.load(Ordering::SeqCst), "task still anchored");
10028        assert!(!old_placeholder.load(Ordering::SeqCst));
10029    }
10030
10031    /// OpenAI-path mirror: the same pipeline serves the second loop — the
10032    /// marker + anchored task reach the post-compression request.
10033    struct OpenAiGauntletResponder {
10034        final_answer: String,
10035        task_in_marker_request: Arc<AtomicBool>,
10036        summary_in_marker_request: Arc<AtomicBool>,
10037    }
10038
10039    impl Respond for OpenAiGauntletResponder {
10040        fn respond(&self, req: &Request) -> ResponseTemplate {
10041            let body = body_json(req);
10042            if messages_contain(&body, SUMMARY_PREFIX) {
10043                if messages_contain(&body, TASK) {
10044                    self.task_in_marker_request.store(true, Ordering::SeqCst);
10045                }
10046                if messages_contain(&body, CANNED_SUMMARY) {
10047                    self.summary_in_marker_request.store(true, Ordering::SeqCst);
10048                }
10049                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10050                    "choices": [{ "message": { "content": self.final_answer } }]
10051                }));
10052            }
10053            if body.get("tools").is_some() {
10054                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10055                    "choices": [{ "message": {
10056                        "content": null,
10057                        "tool_calls": [{
10058                            "id": "call_1",
10059                            "type": "function",
10060                            "function": { "name": "read_file", "arguments": "{\"path\":\"big.txt\"}" }
10061                        }]
10062                    }}]
10063                }))
10064            } else {
10065                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10066                    "choices": [{ "message": { "content": "cap exit" } }]
10067                }))
10068            }
10069        }
10070    }
10071
10072    #[tokio::test]
10073    async fn openai_loop_compresses_with_the_same_pipeline() {
10074        let server = MockServer::start().await;
10075        let task_in_marker = Arc::new(AtomicBool::new(false));
10076        let summary_in_marker = Arc::new(AtomicBool::new(false));
10077        Mock::given(method("POST"))
10078            .and(path("/v1/chat/completions"))
10079            .respond_with(OpenAiGauntletResponder {
10080                final_answer: "openai: marker is GAUNTLET-7f3d9c".into(),
10081                task_in_marker_request: task_in_marker.clone(),
10082                summary_in_marker_request: summary_in_marker.clone(),
10083            })
10084            .mount(&server)
10085            .await;
10086
10087        let ws = gauntlet_workspace();
10088        let workspace = ws.path().to_string_lossy().to_string();
10089        let messages = msgs();
10090        let caveats = Caveats::top();
10091        let uri = server.uri();
10092        let prompts = Arc::new(Mutex::new(Vec::new()));
10093        let summarizer = canned_summarizer(prompts.clone());
10094        let mut compress_state = CompressState::new();
10095        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10096        c.kind = BackendKind::Openai;
10097        c.api_key = Some("sk-test");
10098        c.summarizer = Some(&*summarizer);
10099        c.compress_state = Some(&mut compress_state);
10100        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10101            .await
10102            .expect("openai loop should succeed");
10103
10104        assert_eq!(reply, "openai: marker is GAUNTLET-7f3d9c");
10105        assert!(!prompts.lock().unwrap().is_empty(), "summarizer engaged");
10106        assert!(task_in_marker.load(Ordering::SeqCst));
10107        assert!(summary_in_marker.load(Ordering::SeqCst));
10108    }
10109
10110    // -----------------------------------------------------------------------
10111    // Multi-compression long-haul regressions (review of PR #267, F1/F2/N3):
10112    // the original suite never exercised a SECOND compression — the gap that
10113    // let the self-poisoning boundary bug through.
10114    // -----------------------------------------------------------------------
10115
10116    /// Per-request long-haul observations: `(dispatched message count,
10117    /// length of the last tool-role message — the freshest result)`.
10118    type HaulLog = Arc<Mutex<Vec<(usize, Option<usize>)>>>;
10119
10120    /// Endless-work responder: each round calls a (hallucinated) write-ish
10121    /// tool and then `read_file` of `path` while tools are offered (the loop
10122    /// runs to its round cap), answering only the cap-exit tools-disabled
10123    /// completion. The non-read-only call keeps the loop's read-only nudge
10124    /// quiet — no intervening user messages, the regime the reviewer's F1
10125    /// traces locked up in (a periodic nudge would hand the boundary a fresh
10126    /// anchor and mask the bug). Logs, per tool-offering request, the
10127    /// dispatched message count and the length of the LAST tool-role message
10128    /// — the freshest result the model is about to read.
10129    struct LongHaulResponder {
10130        path: &'static str,
10131        log: HaulLog,
10132    }
10133
10134    impl Respond for LongHaulResponder {
10135        fn respond(&self, req: &Request) -> ResponseTemplate {
10136            let body = body_json(req);
10137            if body.get("tools").is_some() {
10138                let empty = Vec::new();
10139                let msgs = body["messages"].as_array().unwrap_or(&empty);
10140                let last_tool_len = msgs
10141                    .iter()
10142                    .rev()
10143                    .find(|m| m["role"].as_str() == Some("tool"))
10144                    .and_then(|m| m["content"].as_str())
10145                    .map(|c| c.chars().count());
10146                self.log.lock().unwrap().push((msgs.len(), last_tool_len));
10147                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10148                    "message": { "content": "", "tool_calls": [
10149                        { "function": { "name": "apply_patch", "arguments": {} } },
10150                        { "function": { "name": "read_file", "arguments": { "path": self.path } } }
10151                    ]}
10152                }))
10153            } else {
10154                ResponseTemplate::new(200).set_body_json(
10155                    serde_json::json!({ "message": { "content": "long haul done" } }),
10156                )
10157            }
10158        }
10159    }
10160
10161    /// Three prior turns, then the active task — the reviewer's multi-turn
10162    /// shape (the last REAL user message sits deep before the tool rounds).
10163    fn multi_turn_msgs() -> Vec<MemMessage> {
10164        vec![
10165            MemMessage::system("you are a test"),
10166            MemMessage::user("prior turn: inspect the workspace"),
10167            MemMessage::assistant("inspected — looks healthy"),
10168            MemMessage::user("prior turn: run the linters"),
10169            MemMessage::assistant("linters are green"),
10170            MemMessage::user("prior turn: sketch a fix"),
10171            MemMessage::assistant("sketched in my head"),
10172            MemMessage::user(TASK),
10173        ]
10174    }
10175
10176    /// Drive `rounds` tool rounds under count-only compression pressure.
10177    /// Returns `(per-request log, summarizer invocations, reply, latched)`.
10178    async fn run_long_haul(
10179        mem_messages: Vec<MemMessage>,
10180        rounds: usize,
10181        threshold: usize,
10182        file: &'static str,
10183        content: &str,
10184    ) -> (Vec<(usize, Option<usize>)>, usize, String, bool) {
10185        let server = MockServer::start().await;
10186        let log = Arc::new(Mutex::new(Vec::new()));
10187        Mock::given(method("POST"))
10188            .and(path("/api/chat"))
10189            .respond_with(LongHaulResponder {
10190                path: file,
10191                log: log.clone(),
10192            })
10193            .mount(&server)
10194            .await;
10195        let ws = tempfile::TempDir::new().unwrap();
10196        std::fs::write(ws.path().join(file), content).unwrap();
10197        let workspace = ws.path().to_string_lossy().to_string();
10198        let caveats = Caveats::top();
10199        let uri = server.uri();
10200        let prompts = Arc::new(Mutex::new(Vec::new()));
10201        let summarizer = canned_summarizer(prompts.clone());
10202        let mut compress_state = CompressState::new();
10203        let mut c = ctx(&uri, &mem_messages, &caveats, &workspace);
10204        c.max_tool_rounds = rounds;
10205        c.mid_loop_trim_threshold = threshold;
10206        c.mid_loop_trim_tokens = None; // count-only: the F1/F2 regime
10207        c.summarizer = Some(&*summarizer);
10208        c.compress_state = Some(&mut compress_state);
10209        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10210            .await
10211            .expect("the long haul must complete");
10212        let log = log.lock().unwrap().clone();
10213        let calls = prompts.lock().unwrap().len();
10214        (log, calls, reply, compress_state.is_disabled())
10215    }
10216
10217    /// F1 regression (i) — the reviewer's single-turn trace: 4 KB read_file
10218    /// results to round 40 under the count-only trigger. Pre-fix, the second
10219    /// compression anchored on its own summary: the count never shrank again
10220    /// (65 messages at round 40), the summarizer re-fired per round, and the
10221    /// fresh 4 KB result was one-lined before every dispatch from round ~20
10222    /// — the model could never read anything for the rest of the turn.
10223    #[tokio::test]
10224    async fn forty_rounds_single_turn_stay_bounded_with_fresh_results_intact() {
10225        let line = "the quick brown newt compresses context without discarding it\n";
10226        let threshold = 15usize;
10227        let (log, summarizer_calls, reply, latched) =
10228            run_long_haul(msgs(), 40, threshold, "big.txt", &line.repeat(64)).await;
10229
10230        assert_eq!(reply, "long haul done");
10231        assert!(!latched, "count-only pressure must never latch anti-thrash");
10232        assert_eq!(log.len(), 40, "all 40 tool rounds dispatched");
10233        for (round, (len, last_tool)) in log.iter().enumerate() {
10234            assert!(
10235                *len <= threshold + 6,
10236                "round {round}: dispatched {len} messages — the count must stay \
10237                 bounded after every compression (threshold {threshold} + slack)"
10238            );
10239            if let Some(n) = last_tool {
10240                assert!(
10241                    *n > 1_000,
10242                    "round {round}: the fresh tool result was destroyed before \
10243                     dispatch ({n} chars — a one-liner)"
10244                );
10245            }
10246        }
10247        assert!(summarizer_calls >= 2, "the long haul compresses repeatedly");
10248        assert!(
10249            summarizer_calls <= 16,
10250            "summarizer invocations must be bounded, not per-round \
10251             (got {summarizer_calls} in 40 rounds)"
10252        );
10253        let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
10254        println!(
10255            "forty-round trace: max dispatched len {max_len}, \
10256             summarizer calls {summarizer_calls}"
10257        );
10258    }
10259
10260    /// F1 regression (ii) — the reviewer's multi-turn trace: 3 prior turns,
10261    /// then 30 tool rounds. Pre-fix the regime locked in at round ~9 (the
10262    /// anchor pinned the current task, the middle shrank to the previous
10263    /// summary alone, nothing could ever shrink) and the summarizer re-ran
10264    /// almost every round (71 invocations in 80 rounds).
10265    #[tokio::test]
10266    async fn thirty_rounds_multi_turn_stay_bounded_with_fresh_results_intact() {
10267        let line = "the quick brown newt compresses context without discarding it\n";
10268        let threshold = 15usize;
10269        let (log, summarizer_calls, reply, latched) = run_long_haul(
10270            multi_turn_msgs(),
10271            30,
10272            threshold,
10273            "big.txt",
10274            &line.repeat(64),
10275        )
10276        .await;
10277
10278        assert_eq!(reply, "long haul done");
10279        assert!(!latched, "count-only pressure must never latch anti-thrash");
10280        assert_eq!(log.len(), 30, "all 30 tool rounds dispatched");
10281        for (round, (len, last_tool)) in log.iter().enumerate() {
10282            assert!(
10283                *len <= threshold + 6,
10284                "round {round}: dispatched {len} messages — bounded"
10285            );
10286            if let Some(n) = last_tool {
10287                assert!(
10288                    *n > 1_000,
10289                    "round {round}: fresh tool result destroyed pre-dispatch ({n} chars)"
10290                );
10291            }
10292        }
10293        assert!(summarizer_calls >= 2);
10294        assert!(
10295            summarizer_calls <= 14,
10296            "summarizer invocations must be bounded, not per-round \
10297             (got {summarizer_calls} in 30 rounds)"
10298        );
10299        let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
10300        println!(
10301            "thirty-round multi-turn trace: max dispatched len {max_len}, \
10302             summarizer calls {summarizer_calls}"
10303        );
10304    }
10305
10306    /// F2 regression — the reviewer's 600-char-results multi-turn shape:
10307    /// count-only compressions whose per-pass reclaim is small must neither
10308    /// latch anti-thrash (silently killing the VRAM guard) nor escalate to
10309    /// the Refused bail — pre-fix this errored the whole turn at round 25
10310    /// with "context exceeds the model's input budget" while the actual
10311    /// context was ~3-5k tokens and NO token threshold was configured.
10312    #[tokio::test]
10313    async fn count_only_low_reclaim_never_latches_or_bails() {
10314        let (log, _calls, reply, latched) =
10315            run_long_haul(multi_turn_msgs(), 30, 15, "small.txt", &"x".repeat(600)).await;
10316        assert_eq!(reply, "long haul done", "the turn must complete — no bail");
10317        assert!(
10318            !latched,
10319            "count-only passes must never latch the disable switch"
10320        );
10321        assert_eq!(log.len(), 30, "all 30 rounds ran (no Refused early-exit)");
10322    }
10323
10324    // -----------------------------------------------------------------------
10325    // Trailing-group long-hauls (#270 / #285): the read-only-nudge regime the
10326    // #267 re-verifier flagged as untested, and the oversized-single-round
10327    // residual #284's gauntlet measured.
10328    // -----------------------------------------------------------------------
10329
10330    /// Per-request observations for the nudged haul: `(message count, nudge
10331    /// text present, per-result content lengths of the trailing tool group)`.
10332    type NudgedLog = Arc<Mutex<Vec<(usize, bool, Vec<usize>)>>>;
10333
10334    /// Read-only-only responder: every round reads big1 + big2 + small (three
10335    /// read-only calls, no writes), so the loop's read-only nudge fires every
10336    /// few rounds — the regime #270's probe ran in. The #267 long-haul
10337    /// responder deliberately added a write-ish call to keep that nudge quiet
10338    /// and hand the boundary no fresh anchors; this one deliberately does the
10339    /// opposite. Logs, per tool-offering request, the content length of every
10340    /// tool result in the trailing group (everything after the last
10341    /// assistant-with-`tool_calls`).
10342    struct NudgedHaulResponder {
10343        log: NudgedLog,
10344    }
10345
10346    impl Respond for NudgedHaulResponder {
10347        fn respond(&self, req: &Request) -> ResponseTemplate {
10348            let body = body_json(req);
10349            if body.get("tools").is_some() {
10350                let empty = Vec::new();
10351                let msgs = body["messages"].as_array().unwrap_or(&empty);
10352                let group_start = msgs
10353                    .iter()
10354                    .rposition(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()))
10355                    .map(|i| i + 1)
10356                    .unwrap_or(msgs.len());
10357                let group_lens: Vec<usize> = msgs[group_start..]
10358                    .iter()
10359                    .filter(|m| m["role"].as_str() == Some("tool"))
10360                    .filter_map(|m| m["content"].as_str())
10361                    .map(|c| c.chars().count())
10362                    .collect();
10363                let nudged = messages_contain(&body, "read-only rounds so far");
10364                self.log
10365                    .lock()
10366                    .unwrap()
10367                    .push((msgs.len(), nudged, group_lens));
10368                // `role` present like a real Ollama reply — the loop appends
10369                // this object verbatim and the prune pairing reads the role.
10370                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10371                    "message": { "role": "assistant", "content": "", "tool_calls": [
10372                        { "function": { "name": "read_file", "arguments": { "path": "big1.txt" } } },
10373                        { "function": { "name": "read_file", "arguments": { "path": "big2.txt" } } },
10374                        { "function": { "name": "read_file", "arguments": { "path": "small.txt" } } }
10375                    ]}
10376                }))
10377            } else {
10378                ResponseTemplate::new(200).set_body_json(
10379                    serde_json::json!({ "message": { "content": "nudged haul done" } }),
10380                )
10381            }
10382        }
10383    }
10384
10385    /// #270 e2e — the test the #267 re-verifier said was missing: a
10386    /// nudge-active long session (read-only rounds only, so the loop injects
10387    /// its stop-exploring user message right before the compression call
10388    /// site every few rounds) under a hard token trigger. Pre-fix, the
10389    /// nudge zeroed the trailing `role == "tool"` count, `keep_last`
10390    /// floored at 2, and BOTH big fresh results were one-lined pre-dispatch
10391    /// on every nudge round. Post-fix the group derives from the assistant
10392    /// turn that issued the calls: the newest member is always whole and
10393    /// the middle member survives every round (#285's within-group reclaim
10394    /// may one-line only the oldest, oldest-first, and only while over
10395    /// budget).
10396    #[tokio::test]
10397    async fn nudged_long_haul_keeps_fresh_group_results_intact() {
10398        let server = MockServer::start().await;
10399        let log: NudgedLog = Arc::new(Mutex::new(Vec::new()));
10400        Mock::given(method("POST"))
10401            .and(path("/api/chat"))
10402            .respond_with(NudgedHaulResponder { log: log.clone() })
10403            .mount(&server)
10404            .await;
10405        // Distinct contents per file: identical results would engage the
10406        // dedupe pass, which is not what this test pins.
10407        let ws = tempfile::TempDir::new().unwrap();
10408        std::fs::write(
10409            ws.path().join("big1.txt"),
10410            "the first big fixture keeps unseen results intact\n".repeat(200),
10411        )
10412        .unwrap();
10413        std::fs::write(
10414            ws.path().join("big2.txt"),
10415            "the second big fixture must survive every nudge round\n".repeat(200),
10416        )
10417        .unwrap();
10418        std::fs::write(
10419            ws.path().join("small.txt"),
10420            "the small newest fixture stays whole\n".repeat(5),
10421        )
10422        .unwrap();
10423        let workspace = ws.path().to_string_lossy().to_string();
10424        let messages = msgs();
10425        let caveats = Caveats::top();
10426        let uri = server.uri();
10427        let prompts = Arc::new(Mutex::new(Vec::new()));
10428        let summarizer = canned_summarizer(prompts.clone());
10429        let mut compress_state = CompressState::new();
10430        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10431        c.max_tool_rounds = 12;
10432        c.mid_loop_trim_tokens = Some(4_000); // hard trigger, fires most rounds
10433        c.summarizer = Some(&*summarizer);
10434        c.compress_state = Some(&mut compress_state);
10435        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10436            .await
10437            .expect("the nudged haul must complete");
10438
10439        assert_eq!(reply, "nudged haul done");
10440        assert!(
10441            !compress_state.is_disabled(),
10442            "real reclaims every round must never latch anti-thrash"
10443        );
10444        let log = log.lock().unwrap();
10445        for (round, entry) in log.iter().enumerate() {
10446            println!("nudged haul round {round}: {entry:?}");
10447        }
10448        assert_eq!(log.len(), 12, "all 12 tool rounds dispatched");
10449        let nudged_rounds = log.iter().filter(|(_, n, _)| *n).count();
10450        assert!(
10451            nudged_rounds >= 2,
10452            "the read-only nudge regime must actually be active \
10453             (got {nudged_rounds} nudged requests)"
10454        );
10455        for (round, (len, nudged, group_lens)) in log.iter().enumerate() {
10456            if group_lens.is_empty() {
10457                continue; // round 0: no tool group yet
10458            }
10459            assert_eq!(group_lens.len(), 3, "round {round}: pairing intact");
10460            // The newest member is ALWAYS whole.
10461            assert!(
10462                group_lens[2] > 150,
10463                "round {round} (nudged={nudged}): the newest fresh result \
10464                 was destroyed pre-dispatch ({} chars)",
10465                group_lens[2]
10466            );
10467            // The middle member survives too: within-group reclaim is
10468            // oldest-first and stops at the budget — pre-#270 every nudge
10469            // round one-lined it ({len} msgs dispatched).
10470            assert!(
10471                group_lens[1] > 1_000,
10472                "round {round} (nudged={nudged}, {len} msgs): the middle \
10473                 fresh result was destroyed pre-dispatch ({} chars)",
10474                group_lens[1]
10475            );
10476        }
10477        let max_tokens = log.iter().map(|(l, _, _)| *l).max().unwrap();
10478        println!(
10479            "nudged haul trace: {nudged_rounds} nudged rounds, \
10480             max dispatched len {max_tokens}, group lens e.g. {:?}",
10481            log.last().unwrap().2
10482        );
10483    }
10484
10485    /// Per-request observations for the oversized-round haul: `(est message
10486    /// tokens, a one-lined, b one-lined, c payload intact, task present)`.
10487    type OversizedLog = Arc<Mutex<Vec<(usize, bool, bool, bool, bool)>>>;
10488
10489    /// One round reads three files whose results TOGETHER exceed the model
10490    /// window; answers as soon as the dispatch shows a.txt one-lined.
10491    struct OversizedRoundResponder {
10492        log: OversizedLog,
10493    }
10494
10495    impl Respond for OversizedRoundResponder {
10496        fn respond(&self, req: &Request) -> ResponseTemplate {
10497            let body = body_json(req);
10498            let a_onelined = messages_contain(&body, "[read_file] read 'a.txt'");
10499            if !body["stream"].as_bool().unwrap_or(false) {
10500                self.log.lock().unwrap().push((
10501                    body_message_tokens(&body),
10502                    a_onelined,
10503                    messages_contain(&body, "[read_file] read 'b.txt'"),
10504                    messages_contain(&body, "NEWEST-PAYLOAD-C-INTACT"),
10505                    messages_contain(&body, TASK),
10506                ));
10507            }
10508            if a_onelined {
10509                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10510                    "message": { "content": "the three files are summarized" }
10511                }));
10512            }
10513            if body.get("tools").is_some() {
10514                // `role` present like a real Ollama reply — the prune
10515                // pairing needs it to name the file in each one-liner.
10516                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10517                    "message": { "role": "assistant", "content": "", "tool_calls": [
10518                        { "function": { "name": "read_file", "arguments": { "path": "a.txt" } } },
10519                        { "function": { "name": "read_file", "arguments": { "path": "b.txt" } } },
10520                        { "function": { "name": "read_file", "arguments": { "path": "c.txt" } } }
10521                    ]}
10522                }))
10523            } else {
10524                ResponseTemplate::new(200)
10525                    .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
10526            }
10527        }
10528    }
10529
10530    /// #285 e2e — the B6 residual measured in #284's gauntlet: ONE round's
10531    /// tool group alone exceeds the `num_ctx` ceiling (the only budget in
10532    /// play on a fresh capability cache, #282/#284 wiring untouched).
10533    /// Pre-fix the F1c protection made the group unreclaimable: compression
10534    /// honestly reported "still over budget" and the dispatch shipped
10535    /// over-window into a silent backend truncation. Post-fix the dispatch
10536    /// fits the ceiling: a.txt / b.txt one-lined (each naming its file for
10537    /// re-read), c.txt — the newest — intact, the task still present, and
10538    /// the model returns the real answer.
10539    #[tokio::test]
10540    async fn oversized_single_round_dispatches_within_the_window() {
10541        let server = MockServer::start().await;
10542        let log: OversizedLog = Arc::new(Mutex::new(Vec::new()));
10543        Mock::given(method("POST"))
10544            .and(path("/api/chat"))
10545            .respond_with(OversizedRoundResponder { log: log.clone() })
10546            .mount(&server)
10547            .await;
10548        // Three ~7 KB results: together ~5.3k est tokens against a 4,096
10549        // num_ctx (3,276-token input ceiling) — the trailing group ALONE
10550        // exceeds the window; no boundary can split it (B6's shape).
10551        // Distinct contents per file: identical results would engage the
10552        // dedupe pass, which is not what this test pins.
10553        let ws = tempfile::TempDir::new().unwrap();
10554        std::fs::write(
10555            ws.path().join("a.txt"),
10556            "fixture a arrives first in the one giant trailing group\n".repeat(125),
10557        )
10558        .unwrap();
10559        std::fs::write(
10560            ws.path().join("b.txt"),
10561            "fixture b arrives second and is older than the newest\n".repeat(130),
10562        )
10563        .unwrap();
10564        std::fs::write(
10565            ws.path().join("c.txt"),
10566            format!(
10567                "{}NEWEST-PAYLOAD-C-INTACT\n",
10568                "fixture c arrives last and must reach the model whole\n".repeat(130)
10569            ),
10570        )
10571        .unwrap();
10572        let workspace = ws.path().to_string_lossy().to_string();
10573        let messages = msgs();
10574        let caveats = Caveats::top();
10575        let uri = server.uri();
10576        let prompts = Arc::new(Mutex::new(Vec::new()));
10577        let summarizer = canned_summarizer(prompts.clone());
10578        let mut compress_state = CompressState::new();
10579        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10580        // Fresh capability cache, no token threshold: the num_ctx the loop
10581        // itself sends is the only ceiling (#284's regime, untouched).
10582        c.max_ok_input = None;
10583        c.safe_context = None;
10584        c.mid_loop_trim_tokens = None;
10585        c.num_ctx = Some(4_096);
10586        c.summarizer = Some(&*summarizer);
10587        c.compress_state = Some(&mut compress_state);
10588        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10589            .await
10590            .expect("the oversized round must complete");
10591
10592        let log = log.lock().unwrap();
10593        for (i, entry) in log.iter().enumerate() {
10594            println!("oversized round request {i}: {entry:?}");
10595        }
10596        // Never a silent wrong answer: the model answered from real data.
10597        assert_eq!(reply, "the three files are summarized");
10598
10599        // THE #285 property: no dispatch ships over the window. The newest
10600        // result alone fits the ceiling here, so there is no truthful
10601        // still-over residual to excuse an oversized request — pre-fix the
10602        // round-1 dispatch went out at ~5.5k est tokens against 3,276.
10603        for (i, &(tokens, ..)) in log.iter().enumerate() {
10604            assert!(
10605                tokens <= 3_276,
10606                "request {i} dispatched over the window: ~{tokens} est. \
10607                 message tokens > 3,276 (the pre-#285 B6 residual)"
10608            );
10609        }
10610
10611        let (tokens, _, b_onelined, c_intact, task_present) = *log
10612            .iter()
10613            .find(|(_, a, ..)| *a)
10614            .expect("a dispatch with a.txt one-lined must have happened");
10615        assert!(
10616            b_onelined,
10617            "older members one-lined in order: b.txt one-liner missing"
10618        );
10619        assert!(
10620            c_intact,
10621            "#285: the NEWEST result must reach the model whole"
10622        );
10623        assert!(task_present, "the task survives the within-group reclaim");
10624        // The dispatch fits the same input ceiling the #284 test pins:
10625        // 80% of 4,096 = 3,276 estimated message tokens.
10626        assert!(
10627            tokens <= 3_276,
10628            "#285: the reclaimed dispatch must fit the window \
10629             (got ~{tokens} est. message tokens > 3,276)"
10630        );
10631        println!(
10632            "#285 e2e trace: reclaimed dispatch ~{tokens} est. tokens \
10633             (ceiling 3,276), a/b one-lined, c intact"
10634        );
10635    }
10636
10637    /// N3 — the loop-level refusal path end-to-end: a HARD token trigger
10638    /// (`mid_loop_trim_tokens`) over a genuinely incompressible context
10639    /// records two poor passes, latches anti-thrash, and the next
10640    /// over-budget round refuses the dispatch: `chat_complete` errors with
10641    /// the named message. Post-F2 only token-over-budget sessions can reach
10642    /// this — the bail text is now truthful.
10643    #[tokio::test]
10644    async fn hard_budget_thrash_latches_then_bails_with_named_error() {
10645        let server = MockServer::start().await;
10646        let log = Arc::new(Mutex::new(Vec::new()));
10647        Mock::given(method("POST"))
10648            .and(path("/api/chat"))
10649            .respond_with(LongHaulResponder {
10650                path: "tiny.txt",
10651                log: log.clone(),
10652            })
10653            .mount(&server)
10654            .await;
10655        let ws = tempfile::TempDir::new().unwrap();
10656        std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
10657        let workspace = ws.path().to_string_lossy().to_string();
10658        // Incompressible: the system prompt dominates and no structural
10659        // pass or boundary can reduce it.
10660        let messages = vec![
10661            MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
10662            MemMessage::user(TASK),
10663        ];
10664        let caveats = Caveats::top();
10665        let uri = server.uri();
10666        let mut compress_state = CompressState::new();
10667        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10668        c.mid_loop_trim_tokens = Some(50); // hard trigger, unreachable budget
10669        c.compress_state = Some(&mut compress_state);
10670        let err = chat_complete(c, &mut NoMcp)
10671            .await
10672            .expect_err("the post-latch over-budget round must refuse the send");
10673        let msg = err.to_string();
10674        assert!(msg.contains("exceeds the model's input budget"), "{msg}");
10675        assert!(msg.contains("auto-compression is disabled"), "{msg}");
10676        assert!(compress_state.is_disabled(), "anti-thrash latched first");
10677        assert!(
10678            log.lock().unwrap().len() <= 3,
10679            "the refusal must stop the loop within a round or two"
10680        );
10681    }
10682
10683    /// Step 20.3 — the loop-level fail-open path (the gpt-4.1 bug). Same
10684    /// incompressible over-budget shape as the bail test, but the budget rests
10685    /// on the proven-good high-water mark ALONE (`max_ok_input`, no
10686    /// `safe_context`, no `num_ctx`, no token threshold) — the cloud /
10687    /// no-`/api/show` case. Anti-thrash still latches, but the latched
10688    /// over-budget rounds must NOT refuse: refusing here is the death spiral
10689    /// the user hit. The loop keeps dispatching (fails open) and never bails
10690    /// with the named error.
10691    #[tokio::test]
10692    async fn lone_hwm_budget_fails_open_and_does_not_bail() {
10693        let server = MockServer::start().await;
10694        let log = Arc::new(Mutex::new(Vec::new()));
10695        Mock::given(method("POST"))
10696            .and(path("/api/chat"))
10697            .respond_with(LongHaulResponder {
10698                path: "tiny.txt",
10699                log: log.clone(),
10700            })
10701            .mount(&server)
10702            .await;
10703        let ws = tempfile::TempDir::new().unwrap();
10704        std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
10705        let workspace = ws.path().to_string_lossy().to_string();
10706        let messages = vec![
10707            MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
10708            MemMessage::user(TASK),
10709        ];
10710        let caveats = Caveats::top();
10711        let uri = server.uri();
10712        let mut compress_state = CompressState::new();
10713        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10714        // The cloud shape: a starved proven-good HWM and NOTHING authoritative.
10715        c.mid_loop_trim_tokens = None;
10716        c.max_ok_input = Some(50); // largest prompt "seen" — a floor, not a cap
10717        c.safe_context = None; // no /api/show seed
10718        c.num_ctx = None; // no per-request window ceiling
10719        c.compress_state = Some(&mut compress_state);
10720        let result = chat_complete(c, &mut NoMcp).await;
10721        // The session must NOT die on the budget bail — it fails open.
10722        if let Err(e) = &result {
10723            let msg = e.to_string();
10724            assert!(
10725                !msg.contains("exceeds the model's input budget"),
10726                "a lone-HWM budget must never refuse the send: {msg}"
10727            );
10728        }
10729        assert!(
10730            compress_state.is_disabled(),
10731            "anti-thrash still latches on the poor passes"
10732        );
10733        assert!(
10734            log.lock().unwrap().len() > 3,
10735            "the loop kept dispatching past the latch instead of bailing"
10736        );
10737    }
10738}
10739
10740// ---------------------------------------------------------------------------
10741// Per-round observation hook + mid-turn budget raise (Phase 20,
10742// docs/design/model-self-tuning.md §2.2) — wiremock e2e against both gates.
10743// ---------------------------------------------------------------------------
10744
10745#[cfg(test)]
10746mod observation_hook_tests {
10747    use super::*;
10748    use crate::caveats::Caveats;
10749    use crate::{BackendKind, MemMessage};
10750    use std::sync::atomic::{AtomicUsize, Ordering};
10751    use std::sync::Arc;
10752    use wiremock::matchers::{method, path};
10753    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
10754
10755    fn ctx<'a>(
10756        server_uri: &'a str,
10757        messages: &'a [MemMessage],
10758        caveats: &'a Caveats,
10759    ) -> ChatCtx<'a> {
10760        ChatCtx {
10761            url: server_uri,
10762            model: "test-model",
10763            kind: BackendKind::Ollama,
10764            api_key: None,
10765            messages,
10766            task: "do the thing",
10767            workspace: ".",
10768            color: false,
10769            markdown: false,
10770            tool_offload: false,
10771            spill_store: None,
10772            compaction_store: None,
10773            scratchpad: false,
10774            scratchpad_store: None,
10775            code_search: None,
10776            experience_store: None,
10777            step_ledger: None,
10778            caveats,
10779            max_tool_rounds: 8,
10780            workflow_grace_rounds: 0,
10781            tool_output_lines: 20,
10782            debug: false,
10783            trace: false,
10784            num_ctx: None,
10785            connect_timeout_secs: 5,
10786            inference_timeout_secs: 30,
10787            mid_loop_trim_threshold: 40,
10788            mid_loop_trim_tokens: None,
10789            max_ok_input: None,
10790            build_check_cmd: None,
10791            safe_context: None,
10792            recover_cw_400: None,
10793            note_sink: None,
10794            note_nudge: None,
10795            recall_source: None,
10796            memory_source: None,
10797            summarizer: None,
10798            compress_state: None,
10799            tool_events: None,
10800            phantom_reaches: None,
10801            permission_gate: None,
10802            on_round_usage: None,
10803            estimate_ratio: None,
10804            estimation: crate::tokens::TokenEstimation::default(),
10805            summary_input_cap_floor_chars: 8_192,
10806            // #307: test ChatCtx carries no preset exec floor (headless default).
10807            exec_floor: None,
10808            write_ledger: None,
10809            cancel: None,
10810            git_tool: None,
10811            crew_runner: None,
10812        }
10813    }
10814
10815    fn body_json(req: &Request) -> serde_json::Value {
10816        serde_json::from_slice(&req.body).unwrap_or_default()
10817    }
10818
10819    fn is_stream(req: &Request) -> bool {
10820        body_json(req)["stream"].as_bool().unwrap_or(false)
10821    }
10822
10823    fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
10824        let body: String = lines
10825            .iter()
10826            .map(|l| format!("{l}\n"))
10827            .collect::<Vec<_>>()
10828            .join("");
10829        ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
10830    }
10831
10832    /// Tool calls for the first two tools-offering requests (each reporting
10833    /// the backend ACCEPTED an 8,734-token prompt), then a final answer.
10834    struct AcceptsLargePrompts {
10835        tools_rounds: Arc<AtomicUsize>,
10836    }
10837    impl Respond for AcceptsLargePrompts {
10838        fn respond(&self, req: &Request) -> ResponseTemplate {
10839            if is_stream(req) {
10840                return ndjson(&[serde_json::json!({
10841                    "message": {"content": "budget raised, here is the answer"},
10842                    "done": true, "prompt_eval_count": 8_700, "eval_count": 12
10843                })]);
10844            }
10845            let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
10846            if n < 2 {
10847                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10848                    "message": {"content": "", "tool_calls": [{
10849                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
10850                    }]},
10851                    "prompt_eval_count": 8_734, "eval_count": 10,
10852                }))
10853            } else {
10854                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10855                    "message": {"content": "budget raised, here is the answer"},
10856                    "prompt_eval_count": 8_700, "eval_count": 12,
10857                }))
10858            }
10859        }
10860    }
10861
10862    /// THE trace-class regression (the motivating failure): a poisoned-low
10863    /// `max_ok_input` (the largest prompt SEEN, not accepted) used to refuse
10864    /// sends the backend was happily evaluating. Now: the over-budget
10865    /// acceptance (a) reaches the caller as an `Accepted` observation with
10866    /// the backend's real prompt size, and (b) raises the in-turn send
10867    /// budget, so the turn completes instead of latching anti-thrash into
10868    /// the Refused bail across the following rounds.
10869    #[tokio::test]
10870    async fn poisoned_low_budget_recovers_via_accepted_observation_and_raise() {
10871        let server = MockServer::start().await;
10872        let tools_rounds = Arc::new(AtomicUsize::new(0));
10873        Mock::given(method("POST"))
10874            .and(path("/api/chat"))
10875            .respond_with(AcceptsLargePrompts {
10876                tools_rounds: tools_rounds.clone(),
10877            })
10878            .mount(&server)
10879            .await;
10880
10881        // A task big enough (~12k chars ≈ 3k est. tokens) to sit over the
10882        // poisoned 2,000-token budget but far under what the backend accepts.
10883        let big_task = "study the workspace and report. ".repeat(380);
10884        let messages = vec![
10885            MemMessage::system("you are a test"),
10886            MemMessage::user(&big_task),
10887        ];
10888        let caveats = Caveats::top();
10889        let uri = server.uri();
10890        let mut observations: Vec<RoundObservation> = Vec::new();
10891        let mut hook = |obs: RoundObservation| observations.push(obs);
10892        let mut c = ctx(&uri, &messages, &caveats);
10893        c.max_ok_input = Some(2_000); // the poisoned ratchet
10894        c.on_round_usage = Some(&mut hook);
10895        let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
10896            .await
10897            .expect("the turn must complete — no Refused bail after the raise");
10898
10899        assert_eq!(reply, "budget raised, here is the answer");
10900        assert!(
10901            observations.iter().any(|o| matches!(
10902                o,
10903                RoundObservation::Accepted {
10904                    prompt_tokens: 8_734,
10905                    ..
10906                }
10907            )),
10908            "the accepted 8,734-token prompt must reach the hook: {observations:?}"
10909        );
10910        // Every accepted round carried a non-zero chars/4 estimate for
10911        // calibration pairing.
10912        for o in &observations {
10913            if let RoundObservation::Accepted {
10914                estimated_tokens, ..
10915            } = o
10916            {
10917                assert!(*estimated_tokens > 0, "estimate rides along: {o:?}");
10918            }
10919        }
10920    }
10921
10922    /// Always tool calls (with usage) — drives the anti-thrash latch under an
10923    /// unreachable hard token budget so the turn ends in the Refused Err.
10924    struct ToolCallsWithUsage;
10925    impl Respond for ToolCallsWithUsage {
10926        fn respond(&self, req: &Request) -> ResponseTemplate {
10927            if body_json(req).get("tools").is_some() {
10928                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10929                    "message": {"content": "", "tool_calls": [{
10930                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
10931                    }]},
10932                    "prompt_eval_count": 100, "eval_count": 5,
10933                }))
10934            } else {
10935                ResponseTemplate::new(200)
10936                    .set_body_json(serde_json::json!({"message": {"content": "cap exit"}}))
10937            }
10938        }
10939    }
10940
10941    /// A turn that ends `Err` (the Refused bail) STILL delivered the earlier
10942    /// rounds' `Accepted` observations first — evidence at the moment of
10943    /// observation, not in an epilogue the error skips (the spec's headline
10944    /// property). Also pins the bail's new tail: it names the model and the
10945    /// `newt tunings reset` escape hatch.
10946    #[tokio::test]
10947    async fn err_turn_still_delivered_accepted_observations_first() {
10948        let server = MockServer::start().await;
10949        Mock::given(method("POST"))
10950            .and(path("/api/chat"))
10951            .respond_with(ToolCallsWithUsage)
10952            .mount(&server)
10953            .await;
10954
10955        // Incompressible context + unreachable hard token budget → two poor
10956        // passes latch anti-thrash, the next round refuses the send.
10957        let messages = vec![
10958            MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
10959            MemMessage::user("do the thing"),
10960        ];
10961        let caveats = Caveats::top();
10962        let uri = server.uri();
10963        let mut compress_state = CompressState::new();
10964        let mut observations: Vec<RoundObservation> = Vec::new();
10965        let mut hook = |obs: RoundObservation| observations.push(obs);
10966        let mut c = ctx(&uri, &messages, &caveats);
10967        c.mid_loop_trim_tokens = Some(50);
10968        c.compress_state = Some(&mut compress_state);
10969        c.on_round_usage = Some(&mut hook);
10970        let err = chat_complete(c, &mut NoMcp)
10971            .await
10972            .expect_err("the post-latch over-budget round must refuse the send");
10973
10974        let msg = err.to_string();
10975        assert!(msg.contains("exceeds the model's input budget"), "{msg}");
10976        assert!(
10977            msg.contains("newt tunings reset test-model"),
10978            "the bail must name the model's reset command: {msg}"
10979        );
10980        assert!(
10981            observations.iter().any(|o| matches!(
10982                o,
10983                RoundObservation::Accepted {
10984                    prompt_tokens: 100,
10985                    ..
10986                }
10987            )),
10988            "accepted rounds before the bail must have been reported: {observations:?}"
10989        );
10990    }
10991
10992    /// Probe 1: thinking-only (empty content, non-empty `thinking`, generated
10993    /// tokens); the corrective retry then recovers. The hook must see exactly
10994    /// one `ThinkingOnly` (once per turn) plus the recovery's `Accepted`.
10995    struct ThinkingOnlyThenRecover {
10996        probes: Arc<AtomicUsize>,
10997    }
10998    impl Respond for ThinkingOnlyThenRecover {
10999        fn respond(&self, req: &Request) -> ResponseTemplate {
11000            if is_stream(req) {
11001                if self.probes.load(Ordering::SeqCst) <= 1 {
11002                    ndjson(&[serde_json::json!({
11003                        "message": {"content": ""}, "done": true,
11004                        "prompt_eval_count": 9, "eval_count": 4
11005                    })])
11006                } else {
11007                    ndjson(&[serde_json::json!({
11008                        "message": {"content": "recovered after thinking-only"},
11009                        "done": true, "prompt_eval_count": 12, "eval_count": 3
11010                    })])
11011                }
11012            } else {
11013                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
11014                if n == 1 {
11015                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
11016                        "message": {
11017                            "content": "",
11018                            "thinking": "all reasoning, no final text"
11019                        },
11020                        "prompt_eval_count": 10, "eval_count": 2559,
11021                    }))
11022                } else {
11023                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
11024                        "message": {"content": "recovered after thinking-only"},
11025                        "prompt_eval_count": 12, "eval_count": 3,
11026                    }))
11027                }
11028            }
11029        }
11030    }
11031
11032    #[tokio::test]
11033    async fn thinking_only_response_emits_one_thinking_only_observation() {
11034        let server = MockServer::start().await;
11035        let probes = Arc::new(AtomicUsize::new(0));
11036        Mock::given(method("POST"))
11037            .and(path("/api/chat"))
11038            .respond_with(ThinkingOnlyThenRecover {
11039                probes: probes.clone(),
11040            })
11041            .mount(&server)
11042            .await;
11043
11044        let messages = vec![
11045            MemMessage::system("you are a test"),
11046            MemMessage::user("do the thing"),
11047        ];
11048        let caveats = Caveats::top();
11049        let uri = server.uri();
11050        let mut observations: Vec<RoundObservation> = Vec::new();
11051        let mut hook = |obs: RoundObservation| observations.push(obs);
11052        let mut c = ctx(&uri, &messages, &caveats);
11053        c.on_round_usage = Some(&mut hook);
11054        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11055            .await
11056            .expect("the corrective retry recovers the turn");
11057
11058        assert_eq!(reply, "recovered after thinking-only");
11059        let thinking = observations
11060            .iter()
11061            .filter(|o| matches!(o, RoundObservation::ThinkingOnly))
11062            .count();
11063        assert_eq!(thinking, 1, "exactly once per turn: {observations:?}");
11064        assert!(
11065            observations
11066                .iter()
11067                .any(|o| matches!(o, RoundObservation::Accepted { .. })),
11068            "the recovered round is usable output: {observations:?}"
11069        );
11070    }
11071
11072    /// Tool round + final round both reporting a prompt at ≥95% of the
11073    /// request's `num_ctx` — Ollama may have silently dropped the head, so
11074    /// the rounds are window evidence of NOTHING: no `Accepted` observation,
11075    /// no budget raise.
11076    struct TruncationSuspectResponder {
11077        tools_rounds: Arc<AtomicUsize>,
11078    }
11079    impl Respond for TruncationSuspectResponder {
11080        fn respond(&self, req: &Request) -> ResponseTemplate {
11081            if is_stream(req) {
11082                return ndjson(&[serde_json::json!({
11083                    "message": {"content": "suspect answer"}, "done": true,
11084                    "prompt_eval_count": 4_000, "eval_count": 5
11085                })]);
11086            }
11087            let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
11088            if n == 0 {
11089                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11090                    "message": {"content": "", "tool_calls": [{
11091                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11092                    }]},
11093                    // 4,000 ≥ 95% of 4,096 (3,891) — truncation suspect.
11094                    "prompt_eval_count": 4_000, "eval_count": 5,
11095                }))
11096            } else {
11097                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11098                    "message": {"content": "suspect answer"},
11099                    "prompt_eval_count": 4_000, "eval_count": 5,
11100                }))
11101            }
11102        }
11103    }
11104
11105    #[tokio::test]
11106    async fn truncation_suspect_rounds_emit_nothing() {
11107        let server = MockServer::start().await;
11108        let tools_rounds = Arc::new(AtomicUsize::new(0));
11109        Mock::given(method("POST"))
11110            .and(path("/api/chat"))
11111            .respond_with(TruncationSuspectResponder {
11112                tools_rounds: tools_rounds.clone(),
11113            })
11114            .mount(&server)
11115            .await;
11116
11117        let messages = vec![
11118            MemMessage::system("you are a test"),
11119            MemMessage::user("do the thing"),
11120        ];
11121        let caveats = Caveats::top();
11122        let uri = server.uri();
11123        let mut observations: Vec<RoundObservation> = Vec::new();
11124        let mut hook = |obs: RoundObservation| observations.push(obs);
11125        let mut c = ctx(&uri, &messages, &caveats);
11126        c.num_ctx = Some(4_096);
11127        c.on_round_usage = Some(&mut hook);
11128        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11129            .await
11130            .expect("suspect rounds still complete the turn");
11131
11132        assert_eq!(reply, "suspect answer");
11133        assert!(
11134            observations.is_empty(),
11135            "a possibly head-truncated prompt is evidence of nothing: \
11136             {observations:?}"
11137        );
11138    }
11139
11140    /// OpenAI-path mirror: tool round then final content, both with usage —
11141    /// the hook receives `Accepted` for both (no `num_ctx` on this wire, so
11142    /// no truncation gate), and an absent hook stays a no-op.
11143    struct OpenAiAcceptsResponder;
11144    impl Respond for OpenAiAcceptsResponder {
11145        fn respond(&self, req: &Request) -> ResponseTemplate {
11146            if body_json(req).get("tools").is_some()
11147                && !body_json(req)["messages"]
11148                    .as_array()
11149                    .map(|m| m.iter().any(|x| x["role"] == "tool"))
11150                    .unwrap_or(false)
11151            {
11152                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11153                    "choices": [{"message": {
11154                        "content": null,
11155                        "tool_calls": [{
11156                            "id": "call_1",
11157                            "type": "function",
11158                            "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
11159                        }]
11160                    }}],
11161                    "usage": {"prompt_tokens": 5_120, "completion_tokens": 9},
11162                }))
11163            } else {
11164                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11165                    "choices": [{"message": {"content": "openai accepted"}}],
11166                    "usage": {"prompt_tokens": 5_200, "completion_tokens": 11},
11167                }))
11168            }
11169        }
11170    }
11171
11172    #[tokio::test]
11173    async fn openai_loop_reports_accepted_rounds() {
11174        let server = MockServer::start().await;
11175        Mock::given(method("POST"))
11176            .and(path("/v1/chat/completions"))
11177            .respond_with(OpenAiAcceptsResponder)
11178            .mount(&server)
11179            .await;
11180
11181        let messages = vec![
11182            MemMessage::system("you are a test"),
11183            MemMessage::user("do the thing"),
11184        ];
11185        let caveats = Caveats::top();
11186        let uri = server.uri();
11187        let mut observations: Vec<RoundObservation> = Vec::new();
11188        let mut hook = |obs: RoundObservation| observations.push(obs);
11189        let mut c = ctx(&uri, &messages, &caveats);
11190        c.kind = BackendKind::Openai;
11191        c.api_key = Some("sk-test");
11192        c.on_round_usage = Some(&mut hook);
11193        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11194            .await
11195            .expect("openai loop should succeed");
11196
11197        assert_eq!(reply, "openai accepted");
11198        let accepted: Vec<u32> = observations
11199            .iter()
11200            .filter_map(|o| match o {
11201                RoundObservation::Accepted { prompt_tokens, .. } => Some(*prompt_tokens),
11202                _ => None,
11203            })
11204            .collect();
11205        assert_eq!(
11206            accepted,
11207            vec![5_120, 5_200],
11208            "both usable rounds reported, in order: {observations:?}"
11209        );
11210    }
11211
11212    /// Persistent empties (probe AND stream return empty content, no tool
11213    /// calls) at a prompt ≥85% of the configured `safe_context`, with no
11214    /// generated tokens — so the suspicious-empty corrective retry is NOT
11215    /// taken (that path needs `eval_count > 0`). The loop exhausts its two
11216    /// `overflow_retries`, then on the next persistent empty falls through to
11217    /// the silent-overflow exit and must emit exactly one
11218    /// `SuspectedOverflow { prompt_tokens }` carrying the merged (largest
11219    /// single) prompt size — the loop-emission seam that the dispatch-seam
11220    /// `record_overflow` tests at probe.rs cannot reach.
11221    struct PersistentEmptyOverflow;
11222    impl Respond for PersistentEmptyOverflow {
11223        fn respond(&self, _req: &Request) -> ResponseTemplate {
11224            if is_stream(_req) {
11225                // Stream re-issue: empty content, no tokens generated, but the
11226                // round still reports a large evaluated prompt.
11227                return ndjson(&[serde_json::json!({
11228                    "message": {"content": ""}, "done": true,
11229                    "prompt_eval_count": 8_734, "eval_count": 0
11230                })]);
11231            }
11232            // Probe (non-stream): empty content, no tool calls, no generated
11233            // tokens, large evaluated prompt.
11234            ResponseTemplate::new(200).set_body_json(serde_json::json!({
11235                "message": {"content": ""},
11236                "prompt_eval_count": 8_734, "eval_count": 0,
11237            }))
11238        }
11239    }
11240
11241    #[tokio::test]
11242    async fn persistent_empty_over_safe_context_emits_suspected_overflow() {
11243        let server = MockServer::start().await;
11244        Mock::given(method("POST"))
11245            .and(path("/api/chat"))
11246            .respond_with(PersistentEmptyOverflow)
11247            .mount(&server)
11248            .await;
11249
11250        let messages = vec![
11251            MemMessage::system("you are a test"),
11252            MemMessage::user("do the thing"),
11253        ];
11254        let caveats = Caveats::top();
11255        let uri = server.uri();
11256        let mut observations: Vec<RoundObservation> = Vec::new();
11257        let mut hook = |obs: RoundObservation| observations.push(obs);
11258        let mut c = ctx(&uri, &messages, &caveats);
11259        // 8_734 ≥ 85% of 4_000 (3_400) → the silent-overflow gate fires.
11260        c.safe_context = Some(4_000);
11261        c.on_round_usage = Some(&mut hook);
11262        let (_reply, streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
11263            .await
11264            .expect("persistent empties return the empty-response message, not Err");
11265
11266        // Diagnostic exit returns non-streamed placeholder text.
11267        assert!(
11268            !streamed,
11269            "the silent-overflow exit is not a streamed reply"
11270        );
11271        // Exactly one SuspectedOverflow, carrying the merged (largest single)
11272        // prompt size — emitted once at the exit, never per retry.
11273        let overflow: Vec<u32> = observations
11274            .iter()
11275            .filter_map(|o| match o {
11276                RoundObservation::SuspectedOverflow { prompt_tokens } => Some(*prompt_tokens),
11277                _ => None,
11278            })
11279            .collect();
11280        assert_eq!(
11281            overflow,
11282            vec![8_734],
11283            "one SuspectedOverflow at the merged prompt size: {observations:?}"
11284        );
11285        // No Accepted: empty content is never usable output, so the window
11286        // evidence must not ratchet a success.
11287        assert!(
11288            !observations
11289                .iter()
11290                .any(|o| matches!(o, RoundObservation::Accepted { .. })),
11291            "empty rounds are not Accepted evidence: {observations:?}"
11292        );
11293    }
11294}