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// #1004: the `render_report` tool — present collected findings as a rendered
106// Markdown document in the plain scroller (the missing "present" affordance a
107// doer-oriented gather-and-report task otherwise lacks).
108mod report;
109// #714: the `resume_context` tool — a self-scoped read of THIS conversation's
110// own pre-interrupt work (the affordance `recall` structurally cannot be).
111mod resume;
112// FR-3 (#998): the grant-independent absolute deny-list — a fixed exec veto
113// (ssh / rm / systemctl restart …) no capability, mode, or persona unlocks,
114// classified STRUCTURALLY by exec target so a coach's runbook TEXT is untouched.
115mod deny;
116// facade P4 (#780): hidden tool-call routing — promote the model's read-only
117// shell reaches (`cat`/`ls`/`find` + read-only `git`) to a silent rewrite onto
118// the governed built-ins, gate the rest. The route/gate split is pure DATA.
119mod routing;
120// #725: the `tool_search` discovery tool — find a real tool by intent instead
121// of fabricating a foreign name (the structural complement to the #716 alias
122// seam + #717 phantom telemetry).
123mod tool_search;
124mod tools;
125mod transcript;
126mod trim;
127// Scoped FR-14 (#1042): wrap a remote MCP tool's result as explicitly
128// untrusted data before it re-enters context — an injection-guard framing,
129// not a filter.
130mod untrusted;
131mod warmup;
132
133pub use compress::{
134    compress_user_initiated, CompressCounters, CompressState, ManualCompressOutcome, SummarizeFn,
135    SummarizeFuture, Summarizer, CONTINUATION_PREFIX, SUMMARY_END_MARKER, SUMMARY_PREFIX,
136};
137pub use crew_attest::{crew_authz, crew_step_up_policy, CrewAuthz, Presence};
138pub use crew_tool::{compose_roster_tool_definition, crew_tool_definition, CrewRunner};
139pub use display::{
140    fmt_token_gauge, fmt_tokens_compact, gauge_level, print_harness_notice, print_list_item,
141    print_newt, GaugeLevel, NEWT_ORANGE_CT,
142};
143pub use driver::{
144    TurnDriver, TurnDriverConfig, TurnDriverError, TurnOutcome, TurnStatus,
145    VISIBLE_TRANSCRIPT_ROLES,
146};
147pub use experiential::{
148    experience_block, ExperienceStore, SessionExperienceStore, EXPERIENCE_TOP_K,
149};
150pub use git_tool::{git_tool_definition, GitTool};
151pub use markdown::{render_markdown, MarkdownStreamWriter, RenderOpts};
152pub use mcp::{McpTools, NoMcp};
153pub use plan_exec::{run_plan, run_plan_with_reground, NoReground, PlanRun, Reground};
154pub use scheduled::{
155    plan_block, plan_reseat_pointer, PlanSnapshot, SessionStepLedger, Step, StepLedger, StepStatus,
156};
157pub use scratchpad::{scratchpad_state_block, ScratchpadStore, SessionScratchpadStore};
158pub use semantic::{
159    chunk_source, code_evidence_block, code_search_tool_definition, cosine, gather_code_files,
160    index_files, retrieve_evidence, CodeChunk, CodeSearch, Embedder, EmbeddingsClient,
161    SemanticIndex, SessionSemanticIndex,
162};
163pub use spill::{SessionSpillStore, SpillStore};
164
165/// Align GFM table pipes in Markdown **source** (Step 25.5, #568) — plain text,
166/// no ANSI. The headless **wyvern** tier keeps Markdown as source (no rendering),
167/// so this tidies ragged pipe tables for transcripts other agents read. It is
168/// **independent of the `markdown` feature** (wyvern builds `--no-default-features`)
169/// and gated on the optional `markdown-table-formatter` feature: identity unless
170/// enabled, so it never pulls comrak/wasm-bindgen into a default build.
171#[cfg(feature = "markdown-table-formatter")]
172pub fn tidy_markdown_tables(src: &str) -> String {
173    markdown_table_formatter::format_tables(src)
174}
175
176/// Identity passthrough when the `markdown-table-formatter` feature is off.
177#[cfg(not(feature = "markdown-table-formatter"))]
178pub fn tidy_markdown_tables(src: &str) -> String {
179    src.to_string()
180}
181
182#[cfg(test)]
183mod tidy_tables_tests {
184    #[cfg(not(feature = "markdown-table-formatter"))]
185    #[test]
186    fn identity_without_the_feature() {
187        // The default build (and the wyvern strip without the opt-in) leaves the
188        // source untouched.
189        let ragged = "| a | bb |\n|---|---|\n| ccc | d |";
190        assert_eq!(super::tidy_markdown_tables(ragged), ragged);
191    }
192
193    #[cfg(feature = "markdown-table-formatter")]
194    #[test]
195    fn aligns_pipes_with_the_feature() {
196        let ragged = "| a | bb |\n| --- | --- |\n| ccc | d |\n";
197        let tidy = super::tidy_markdown_tables(ragged);
198        assert_ne!(tidy, ragged, "the table should be reformatted");
199        assert!(tidy.contains("ccc"), "content preserved");
200        // Every pipe-bearing row lines its pipes up at the same columns.
201        let pipe_cols = |s: &str| {
202            s.char_indices()
203                .filter(|(_, c)| *c == '|')
204                .map(|(i, _)| i)
205                .collect::<Vec<_>>()
206        };
207        let rows: Vec<&str> = tidy.lines().filter(|l| l.contains('|')).collect();
208        let first = pipe_cols(rows[0]);
209        for r in &rows {
210            assert_eq!(pipe_cols(r), first, "pipes aligned across rows: {r:?}");
211        }
212    }
213}
214pub use budget::get_context_remaining_tool_definition;
215pub use memory_fetch::{
216    memory_fetch_tool_definition, MemAddr, MemPayload, MemorySource, StoreMemorySource,
217};
218pub use note_sink::{save_note_tool_definition, NoteNudge, NoteSink};
219pub use observation::{ShellObservation, SHELL_OBSERVATION_PREFIX};
220pub use permissions::{
221    append_denial, load_denials, widen_caveats, DenialKind, PermissionDecision, PermissionGate,
222    PermissionRecord, PermissionRequest, PersistentDenial,
223};
224pub use recall::{recall_tool_definition, RecallSource, StoreRecallSource};
225pub use resume::resume_context_tool_definition;
226pub use tools::{
227    execute_tool, execute_tool_with_offload, filter_advertised_tools, full_access_requested,
228    ocap_disabled, persona_tool_allowed, set_max_output_tokens, set_output_head_tokens,
229    tool_definitions, venv_cmd_prefix,
230};
231pub use transcript::{
232    transcript_lines, transcript_lines_styled, TranscriptLine, TranscriptRole, TranscriptStyle,
233};
234pub use trim::trim_for_summary;
235pub use untrusted::wrap_untrusted;
236pub use warmup::warmup_if_cold;
237
238use crate::retry::{with_backoff_notify, RetryPolicy};
239use compress::{compress, compression_trigger, CompressAction, CompressRequest};
240use crossterm::{
241    execute,
242    style::{Color as CtColor, Print, ResetColor, SetForegroundColor},
243};
244use display::{
245    emit_compression_notice, emit_overflow_notice, print_debug, print_retry_indicator, print_trace,
246};
247use std::io::{self, Write as _};
248use tools::{is_hallucination, merged_tool_definitions};
249use trim::{
250    estimate_request_tokens, estimate_tokens, estimate_value_tokens, merge_round_usage,
251    ollama_usage, openai_usage, PromptTracker,
252};
253
254/// Retry policy for TUI inference calls: more patient than the hosted-API
255/// default because local DGX nodes can drop for 30–60 s under load.
256/// Total resilience window: ~90 s (2+4+8+16+30+30 s between attempts).
257/// All thresholds are overridable via the standard `NEWT_HTTP_*` env vars.
258fn tui_retry_policy() -> RetryPolicy {
259    RetryPolicy::for_local_inference()
260}
261
262/// Hook recovering a hard context-window 400:
263/// `(error, model, today) → new input-token cap`. See [`ChatCtx::recover_cw_400`].
264pub type RecoverCw400 = fn(&anyhow::Error, &str, &str) -> Option<u32>;
265
266/// One per-round capability observation, reported through
267/// [`ChatCtx::on_round_usage`] at the moment it is observed (Phase 20,
268/// `docs/design/model-self-tuning.md` §2.2 — the `recover_cw_400` pattern,
269/// generalized to the success direction). Evidence must not wait for a turn
270/// epilogue an error can skip: the motivating failure discarded a backend-
271/// accepted 8,734-token prompt because the turn later ended in `Err`.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum RoundObservation {
274    /// Backend evaluated `prompt_tokens` and the round produced a usable
275    /// response (tool calls or non-empty content). `estimated_tokens` is the
276    /// loop's chars/4 estimate of the same request, for calibration.
277    /// Quality-gated AND truncation-gated at the emission sites: never
278    /// emitted when the prompt was within 5% of the request's `num_ctx`
279    /// (Ollama may have silently dropped the head of the prompt).
280    Accepted {
281        prompt_tokens: u32,
282        estimated_tokens: usize,
283    },
284    /// Persistent empty responses at `prompt_tokens` after retries (the
285    /// 85%-of-safe-context silent-overflow exit).
286    SuspectedOverflow { prompt_tokens: u32 },
287    /// Response carried only non-content fields (thinking/reasoning) with
288    /// empty content.
289    ThinkingOnly,
290}
291
292/// Input-token ceiling implied by the `num_ctx` THIS request will carry
293/// (issue #282, the B6 hole): `num_ctx` caps the backend's whole KV window —
294/// input *plus* reply — so the input budget is 80 % of it, the same reply/
295/// estimate headroom the probe's budget math already reserves against a hard
296/// window (`safe_context` bootstraps at 80 % of the declared window; a cw-400
297/// sets `max_ok_input` to 80 % of the parsed limit). `None` (model-default
298/// window) or zero contributes nothing — the zero-is-disabled contract (F3),
299/// never a compress-to-zero.
300fn num_ctx_input_ceiling(num_ctx: Option<u32>, input_ceiling_pct: u32) -> Option<usize> {
301    num_ctx
302        .map(|c| (c as usize) * (input_ceiling_pct as usize) / 100)
303        .filter(|&c| c > 0)
304}
305
306/// Initial pre-send budget for one turn (issue #282; Phase 20 semantics per
307/// `docs/design/model-self-tuning.md` §2.1): the empirically-cached figure is
308/// `max(max_ok_input, safe_context)` composed, via `min`, with the
309/// [`num_ctx_input_ceiling`] of the `num_ctx` the loop is about to send.
310///
311/// `max_ok_input` is a high-water mark of PROVEN-good input — a floor, not a
312/// ceiling. Preferring it over `safe_context` (the pre-Phase-20 contract)
313/// turned "largest prompt seen so far" into a cap, which is the motivating
314/// failure: a stale 6,068 ratchet refused sends the backend was accepting at
315/// 8,734 tokens. `max()` lets whichever of proven-good and believed-safe is
316/// larger drive the budget. The cw-400 path already reins `safe_context`
317/// down to its authoritative cap, so after a hard 400 `max()` still lands on
318/// the authoritative number.
319///
320/// The `num_ctx` ceiling composition is unchanged: before #282 the budget
321/// was the cached numbers alone — unset on a fresh capability cache until
322/// the turn ENDS, so the first turn of a session had no effective ceiling
323/// and a 41k-token request sailed into a forced 4,096 window with zero
324/// compression events (the measured B6 failure: 8/10 silently wrong). The
325/// ceiling is a real token budget: when it fires the trigger, `hard_budget`
326/// semantics apply (consults + feeds anti-thrash).
327fn initial_send_budget(
328    max_ok_input: Option<u32>,
329    safe_context: Option<u32>,
330    num_ctx: Option<u32>,
331    input_ceiling_pct: u32,
332) -> Option<usize> {
333    let cached = match (max_ok_input, safe_context) {
334        (Some(m), Some(s)) => Some(m.max(s) as usize),
335        (m, s) => m.or(s).map(|c| c as usize),
336    };
337    match (cached, num_ctx_input_ceiling(num_ctx, input_ceiling_pct)) {
338        (Some(budget), Some(ceiling)) => Some(budget.min(ceiling)),
339        (budget, ceiling) => budget.or(ceiling),
340    }
341}
342
343/// Convert a chars/4 estimate into real (backend-reported) token space using
344/// the learned per-model `estimate_ratio` (Phase 20,
345/// `docs/design/model-self-tuning.md` §2.3). Ceiling: estimates must err on
346/// the side of counting, never undercounting — the 18.1 rule.
347fn calibrate_up(est: usize, ratio: f32) -> usize {
348    (est as f32 * ratio).ceil() as usize
349}
350
351/// Convert a real-token budget into estimate (chars/4) space — the currency
352/// the compression pipeline measures and reclaims in (Phase 20 §2.3).
353/// Floor: a tighter target is safer than a looser one.
354fn calibrate_down(real: usize, ratio: f32) -> usize {
355    (real as f32 / ratio).floor() as usize
356}
357
358/// Sanitize a per-model `estimate_ratio` for one turn (Phase 20 §2.3): only
359/// finite values inside the learning clamp [0.5, 3.0] are trusted; anything
360/// else (absent, NaN, a corrupted cache entry) degrades to 1.0 — the
361/// identity, i.e. exactly the pre-calibration behavior.
362fn sanitize_estimate_ratio(estimate_ratio: Option<f32>) -> f32 {
363    estimate_ratio
364        .filter(|r| r.is_finite() && (0.5..=3.0).contains(r))
365        .unwrap_or(1.0)
366}
367
368/// Report one quality-gated [`RoundObservation::Accepted`] (Phase 20 §2.2).
369/// Called only from usable-output control paths (tool calls or non-empty
370/// content — the quality gate); skips when the prompt was truncation-suspect
371/// (≥95% of the request's `num_ctx`, where Ollama may have silently dropped
372/// the head) or when the backend reported no usage for the round.
373fn emit_accepted(
374    hook: &mut Option<&mut dyn FnMut(RoundObservation)>,
375    round_usage: Option<crate::TokenUsage>,
376    truncation_suspect: bool,
377    estimated_tokens: usize,
378) {
379    if truncation_suspect {
380        return;
381    }
382    if let (Some(hook), Some(u)) = (hook.as_deref_mut(), round_usage) {
383        hook(RoundObservation::Accepted {
384            prompt_tokens: u.input_tokens,
385            estimated_tokens,
386        });
387    }
388}
389
390// Unit tests for the #282 budget wiring: the `num_ctx` a request will carry
391// must participate in the pre-send budget — composing with the cached
392// capability numbers via `min`, vanishing when absent, and never turning a
393// zero/absent window into a compress-to-zero (F3).
394#[cfg(test)]
395mod send_budget_tests {
396    use super::compress::compression_trigger;
397    use super::{initial_send_budget, num_ctx_input_ceiling};
398
399    /// THE B6 first-turn hole: a fresh capability cache (no `max_ok_input`,
400    /// no `safe_context`) used to mean NO budget at all even though the
401    /// request itself carried `options.num_ctx = 4096`. The ceiling must now
402    /// arm the trigger on turn 1 — as a HARD budget (anti-thrash semantics).
403    #[test]
404    fn first_turn_fresh_cache_trigger_sees_the_num_ctx_ceiling() {
405        let budget = initial_send_budget(None, None, Some(4096), 80);
406        assert_eq!(budget, Some(3276), "80% of 4096 — reply headroom reserved");
407        // The measured B6 shape: ~41k estimated tokens, 3 messages, no
408        // count/token thresholds in reach — pre-fix this returned None and
409        // the request sailed into the 4k window with zero events.
410        let trigger = compression_trigger(3, 41_355, 39_900, 40, None, budget, 1_432)
411            .expect("the ceiling must fire the trigger on the first turn");
412        assert!(trigger.hard_budget, "a real token budget, not a soft halve");
413        assert_eq!(
414            trigger.budget,
415            3_276 - 1_432,
416            "budget lands in message space: ceiling minus tool-schema tokens"
417        );
418        assert_eq!(trigger.max_messages, None, "no count firing here");
419    }
420
421    /// Absent `num_ctx` → exactly the cached-numbers budget (no ceiling).
422    /// CONTRACT CHANGED in Phase 20 (docs/design/model-self-tuning.md §2.1):
423    /// the cached figure is now `max(max_ok_input, safe_context)` — the
424    /// high-water mark is a floor of proven-good, not a ceiling, so it must
425    /// never pull the budget BELOW the believed-safe window.
426    #[test]
427    fn absent_num_ctx_leaves_the_budget_unchanged() {
428        assert_eq!(initial_send_budget(None, None, None, 80), None);
429        assert_eq!(
430            initial_send_budget(Some(2_000), None, None, 80),
431            Some(2_000)
432        );
433        assert_eq!(
434            initial_send_budget(None, Some(5_000), None, 80),
435            Some(5_000)
436        );
437        assert_eq!(
438            initial_send_budget(Some(2_000), Some(5_000), None, 80),
439            Some(5_000),
440            "an HWM below safe_context is a floor, not a cap — safe_context wins"
441        );
442        // And with no budget at all, the trigger stays silent regardless of size.
443        assert_eq!(
444            compression_trigger(3, 41_355, 39_900, 40, None, None, 1_432),
445            None
446        );
447    }
448
449    /// Phase 20 §2.1 — the max(proven, believed) contract, all three shapes:
450    /// HWM below the claim, HWM above the claim (proven beyond it), and the
451    /// post-cw-400 shape where `safe_context` was reined to the authoritative
452    /// cap so `max()` still lands on the authoritative number.
453    #[test]
454    fn cached_budget_is_max_of_proven_and_believed() {
455        // The motivating failure: max_ok_input ratcheted to 6,068 (largest
456        // prompt SEEN) while safe_context believed 80% of a 32k window safe.
457        // Pre-fix the 6,068 won and refused sends the backend accepted.
458        assert_eq!(
459            initial_send_budget(Some(6_068), Some(26_214), None, 80),
460            Some(26_214),
461            "HWM below safe_context → safe_context"
462        );
463        // Proven beyond the claim: an accepted 8,734-token prompt outranks a
464        // conservative claim-derived window.
465        assert_eq!(
466            initial_send_budget(Some(8_734), Some(6_553), None, 80),
467            Some(8_734),
468            "HWM above safe_context (proven beyond the claim) → HWM"
469        );
470        // cw-400-reined shape (#223): the 400 set max_ok_input to 80% of the
471        // endpoint's reported hard limit (authoritative, may be HIGH) and
472        // reined safe_context down to equal-or-lower — max() must land on
473        // the authoritative cap, not regress to the VRAM-capped figure.
474        assert_eq!(
475            initial_send_budget(Some(800_000), Some(64_000), None, 80),
476            Some(800_000),
477            "post-cw-400: max_ok_input is the authoritative cap"
478        );
479        assert_eq!(
480            initial_send_budget(Some(800_000), Some(800_000), None, 80),
481            Some(800_000)
482        );
483    }
484
485    /// Phase 20 §2.3 — the calibration converters: ratio 1.0 is the identity,
486    /// estimate→real rounds UP (must-err-on-counting, the 18.1 rule),
487    /// real→estimate rounds DOWN (a tighter compression target is safer).
488    #[test]
489    fn calibration_helpers_round_in_the_safe_direction() {
490        use super::{calibrate_down, calibrate_up, sanitize_estimate_ratio};
491        // Identity at 1.0 — the no-calibration baseline is exact.
492        assert_eq!(calibrate_up(6_068, 1.0), 6_068);
493        assert_eq!(calibrate_down(6_068, 1.0), 6_068);
494        // The measured nemotron3 shape: chars/4 undercounts ~30% (×1.3).
495        assert_eq!(calibrate_up(1_000, 1.3), 1_300);
496        assert_eq!(calibrate_down(1_000, 1.3), 769, "floor, never round up");
497        // Fractional results: up ceils, down floors.
498        assert_eq!(calibrate_up(3, 1.5), 5, "4.5 ceils to 5");
499        assert_eq!(calibrate_down(3, 2.0), 1, "1.5 floors to 1");
500        // Sanitizer: absent / NaN / out-of-clamp all degrade to identity.
501        assert_eq!(sanitize_estimate_ratio(None), 1.0);
502        assert_eq!(sanitize_estimate_ratio(Some(f32::NAN)), 1.0);
503        assert_eq!(sanitize_estimate_ratio(Some(0.1)), 1.0);
504        assert_eq!(sanitize_estimate_ratio(Some(5.0)), 1.0);
505        assert_eq!(sanitize_estimate_ratio(Some(1.29)), 1.29);
506        assert_eq!(sanitize_estimate_ratio(Some(0.5)), 0.5, "clamp inclusive");
507        assert_eq!(sanitize_estimate_ratio(Some(3.0)), 3.0, "clamp inclusive");
508    }
509
510    /// Phase 20 §2.3 — currency composition at the trigger boundary: a
511    /// real-token send budget minus calibrated-up tool tokens, fired through
512    /// the trigger, then calibrated DOWN into the pipeline's chars/4 space,
513    /// must equal converting each leg separately (the e2e wiring in both
514    /// loops relies on this composition).
515    #[test]
516    fn calibration_composes_across_the_trigger_boundary() {
517        use super::{calibrate_down, calibrate_up};
518        let cal = 1.3_f32;
519        let send_budget = 8_734_usize; // real tokens
520        let tool_tokens_est = 1_000_usize; // chars/4 estimate
521        let tool_tokens_real = calibrate_up(tool_tokens_est, cal); // 1,300
522        let current_real = calibrate_up(9_000, cal); // estimate → real
523        let trigger = compression_trigger(
524            3,
525            current_real,
526            9_000,
527            40,
528            None,
529            Some(send_budget),
530            tool_tokens_real,
531        )
532        .expect("over-budget context fires the guard");
533        assert!(trigger.hard_budget);
534        // trigger.budget is real space (send budget minus real tool tokens);
535        // the pipeline target converts it back to estimate space.
536        assert_eq!(trigger.budget, send_budget - tool_tokens_real);
537        let pipeline_budget = calibrate_down(trigger.budget, cal);
538        assert_eq!(pipeline_budget, calibrate_down(8_734 - 1_300, cal));
539        assert!(
540            pipeline_budget < trigger.budget,
541            "ratio > 1: the estimate-space target is tighter than the real one"
542        );
543    }
544
545    /// The ceiling composes with existing budgets via `min` — whichever is
546    /// tighter wins, in both directions.
547    #[test]
548    fn ceiling_composes_with_cached_budgets_via_min() {
549        // Cached cap tighter than the ceiling: cached wins (mid-loop B5
550        // behavior is untouched by #282).
551        assert_eq!(
552            initial_send_budget(Some(2_135), None, Some(4_096), 80),
553            Some(2_135)
554        );
555        // Ceiling tighter than the cached cap: the B6 shape — bootstrap
556        // safe_context 104,857 vs forced num_ctx 4,096.
557        assert_eq!(
558            initial_send_budget(None, Some(104_857), Some(4_096), 80),
559            Some(3_276)
560        );
561        assert_eq!(
562            initial_send_budget(Some(104_857), Some(104_857), Some(4_096), 80),
563            Some(3_276)
564        );
565    }
566
567    /// Zero/tiny `num_ctx` must never become a compress-to-zero budget — the
568    /// zero-is-disabled contract (F3) holds at the source.
569    #[test]
570    fn zero_or_tiny_num_ctx_is_no_budget_at_all() {
571        assert_eq!(num_ctx_input_ceiling(None, 80), None);
572        assert_eq!(num_ctx_input_ceiling(Some(0), 80), None);
573        assert_eq!(
574            num_ctx_input_ceiling(Some(1), 80),
575            None,
576            "80% rounds to zero"
577        );
578        assert_eq!(initial_send_budget(None, None, Some(0), 80), None);
579        // A zero ceiling must not shadow a real cached budget either.
580        assert_eq!(
581            initial_send_budget(Some(2_000), None, Some(0), 80),
582            Some(2_000)
583        );
584    }
585}
586
587/// Everything one agentic turn needs, resolved once by the caller (the TUI
588/// resolves config + capability cache + caveats per turn and threads them in
589/// here, so the loop itself never re-reads config from disk).
590pub struct ChatCtx<'a> {
591    pub url: &'a str,
592    pub model: &'a str,
593    /// Wire protocol of the active backend (Ollama vs OpenAI-compatible).
594    pub kind: crate::BackendKind,
595    /// Bearer token for authenticated OpenAI-compatible endpoints.
596    pub api_key: Option<&'a str>,
597    /// Full message list already assembled by `MemoryManager::build_messages`.
598    pub messages: &'a [crate::MemMessage],
599    pub task: &'a str,
600    pub workspace: &'a str,
601    pub color: bool,
602    /// Render assistant Markdown as ANSI in the live stream (Step 25.4, #568).
603    /// Resolved by the caller as `[tui].markdown` (∧ `/markdown` override) ∧
604    /// color. The loop only renders when this is true.
605    pub markdown: bool,
606    /// Offload oversized tool results to the session spill store (Step 26.3,
607    /// #584). The resolved `tool_offload` composable feature (Step 26.1); false
608    /// for headless/eval callers (bit-for-bit unchanged when off).
609    pub tool_offload: bool,
610    /// Session spill store for `tool_offload` (Step 26.3). `None` = no offload
611    /// (and `spill:` re-reads resolve to a labelled absence). Shared `&dyn`
612    /// (interior mutability) so it serves both the write path and `memory_fetch`.
613    pub spill_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
614    /// Session compaction store (#661 group B): the compressor stores each
615    /// evicted (redacted) middle span here and names a `compaction:<id>` handle
616    /// in the marker, so the model can losslessly recover a detail the summary
617    /// dropped. SEPARATE store from `spill_store` (own id space). `None` =
618    /// lossy-only compaction (headless / progressive disclosure off).
619    pub compaction_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
620    /// Inject the `<state>` scratchpad block + advertise the state tools (Step
621    /// 26.4, #583). The resolved `scratchpad` feature; false for headless/eval.
622    pub scratchpad: bool,
623    /// Session scratchpad store (Step 26.4). `None` = state tools not advertised
624    /// and no `<state>` injected. Shared `&dyn` (interior mutability).
625    pub scratchpad_store: Option<&'a dyn crate::agentic::scratchpad::ScratchpadStore>,
626    /// Semantic searcher for the `code_search` tool (Step 26.5.5). `None` = the
627    /// tool is not advertised (semantic off / no index). Bundles embedder+index.
628    pub code_search: Option<crate::agentic::semantic::CodeSearch<'a>>,
629    /// Experiential store for the record/recall tools (Step 26.6a). `None` = the
630    /// tools are not advertised (experiential off). Shared `&dyn` (interior mut).
631    pub experience_store: Option<&'a dyn crate::agentic::experiential::ExperienceStore>,
632    /// Plan ledger for the update_plan tool (Step 26.6b). `None` = the tool is
633    /// not advertised (scheduled off). Shared `&dyn` (interior mut).
634    pub step_ledger: Option<&'a dyn crate::agentic::scheduled::StepLedger>,
635    pub caveats: &'a crate::caveats::Caveats,
636    /// FR-1 part 2 (#997): the active persona's tool allow-list (its `tools:`
637    /// front-matter), or `None` when no persona is active / the persona sets no
638    /// list. When `Some`, the loop advertises ONLY these tools (plus the
639    /// always-on infra tools the loop can't run without), and the executor
640    /// REFUSES any tool outside the list — a name-scoped complement to the
641    /// axis-scoped `caveats` (which part 1, #1002, already meets in). Headless
642    /// / driver / eval callers pass `None` (no persona surface).
643    pub persona_tools: Option<&'a [String]>,
644    /// Maximum tool-call rounds before forcing a final tools-disabled
645    /// completion (from `[tui].max_tool_rounds`, default 40).
646    pub max_tool_rounds: usize,
647    /// Additional progress-aware rounds available after `max_tool_rounds` when
648    /// the active workflow still has incomplete work and the recent rounds show
649    /// repair evidence or concrete progress. `0` makes the normal cap hard.
650    pub workflow_grace_rounds: usize,
651    /// Max narrate-then-stop rescue nudges per turn (from
652    /// `[tui].narration_nudge_cap`, default 1; `[[model_tuning]]` can override
653    /// per model). Once spent, the next no-tool narration is accepted as the
654    /// turn's final answer. The second and later nudges escalate: they name
655    /// the active plan step and demand a bare tool call (lever L3,
656    /// docs/design/next-loop-levers.md).
657    pub narration_nudge_cap: usize,
658    /// Max lines of tool output shown inline (from `[tui].tool_output_lines`,
659    /// default 20). Resolved once per turn and threaded to `execute_tool` so
660    /// the tool loop never re-reads config from disk.
661    pub tool_output_lines: usize,
662    /// Enable per-round diagnostic output. Set via `NEWT_DEBUG=1` or the
663    /// `[tui] debug = true` config key.
664    pub debug: bool,
665    /// Enable deeper backend compatibility traces. Set via `NEWT_TRACE=1` or
666    /// the `[tui] trace = true` config key.
667    pub trace: bool,
668    /// Ollama `options.num_ctx` — caps KV-cache allocation to prevent VRAM
669    /// exhaustion on large models. `None` → model default (often 131k).
670    /// Also feeds the pre-send budget as a hard input ceiling for this turn's
671    /// requests (issue #282): Ollama silently evaluates only the window's
672    /// tail, so anything newt sends must already fit inside the `num_ctx` it
673    /// sends it with. Ignored on the OpenAI path (no such request field).
674    pub num_ctx: Option<u32>,
675    /// TCP connect timeout. Short (5 s default) so a down endpoint fails fast
676    /// rather than blocking the full `inference_timeout_secs`.
677    pub connect_timeout_secs: u64,
678    /// Total inference timeout. Must be long enough for the model to generate
679    /// a complete response (120 s default).
680    pub inference_timeout_secs: u64,
681    /// Message list size at which the agent trims the middle of the in-flight
682    /// conversation to prevent context overflow mid-turn.
683    pub mid_loop_trim_threshold: usize,
684    /// Estimated-token threshold that also triggers a mid-loop trim, regardless
685    /// of message count. Guards against a single huge tool result blowing past
686    /// the context window in one round (from `[tui].mid_loop_trim_tokens`).
687    /// `None` disables token-based trimming. See issue #223.
688    pub mid_loop_trim_tokens: Option<usize>,
689    /// Highest input-token count this model has accepted without a 400, from
690    /// `model-capabilities.json`. Used as the pre-send budget gate: requests
691    /// estimated to exceed it are trimmed *before* dispatch. `None` falls back
692    /// to `safe_context`. See issue #223.
693    pub max_ok_input: Option<u32>,
694    /// Shell command run after every successful file write to give the model
695    /// immediate ground-truth feedback (e.g. "cargo check -q --workspace").
696    /// `None` disables auto-checking. Set per-workspace in `.newt/config.toml`.
697    pub build_check_cmd: Option<String>,
698    /// Empirically derived safe context size for this model (input tokens).
699    /// Used to detect likely overflow when the model returns an empty response.
700    /// Sourced from `model-capabilities.json` via `ensure_context_window`.
701    /// `None` disables overflow detection.
702    pub safe_context: Option<u32>,
703    /// Hook invoked when a dispatch fails, to recover a hard context-window
704    /// 400: `(error, model, today) → new input-token cap`. The TUI wires its
705    /// `recover_context_window_400` (which parses the endpoint's real limit
706    /// and persists it to `model-capabilities.json` — that cache stays
707    /// TUI-side with the probe module). `None` disables recovery: the error
708    /// propagates exactly as it did when no limit could be parsed. See #223.
709    pub recover_cw_400: Option<RecoverCw400>,
710    /// Model-writable note store behind the `save_note` tool (Step 19.3,
711    /// #248). `None` ⇒ the tool is not advertised and the loop never writes
712    /// memory (eval / headless callers unaffected). The TUI passes a sink
713    /// over its session `MemoryManager`, so `save_note` and `/remember`
714    /// share one store, one security scan, one char budget.
715    pub note_sink: Option<&'a mut dyn NoteSink>,
716    /// Turn-counted memory-nudge state ([`NoteNudge`]), owned by the caller
717    /// across user turns and lent to the loop per call. Consulted only when
718    /// `note_sink` is present; `None` disables the nudge.
719    pub note_nudge: Option<&'a mut NoteNudge>,
720    /// Read-only search over PAST conversations behind the `recall` tool
721    /// (Step 17.5, #246). `None` ⇒ the tool is not advertised and the loop
722    /// never searches (eval / headless callers unaffected). The TUI passes
723    /// a [`StoreRecallSource`] over its session `ConversationStore` —
724    /// workspace-fenced, current conversation excluded.
725    pub recall_source: Option<&'a dyn RecallSource>,
726    /// Read-only pull of an ADDRESSED memory item behind the `memory_fetch`
727    /// tool (progressive-disclosure memory, Workstream A MVP, #319). `None` ⇒
728    /// the tool is not advertised and the loop never fetches — eval / headless
729    /// / ACP callers (which pass `memory_source: None`) are unaffected,
730    /// bit-for-bit. The TUI passes a [`StoreMemorySource`] over its session
731    /// `NoteStore` + `ConversationStore` (workspace-fenced), so `note:` and
732    /// `turn:` addresses resolve against the same surfaces `/remember` and
733    /// `recall` use. Gated exactly like `recall_source`.
734    pub memory_source: Option<&'a dyn MemorySource>,
735    /// Compression summarizer (Step 18.4, #247): given the redacted summary
736    /// request, returns the summary text — typically one tools-disabled
737    /// completion against the same backend (the TUI wires this, mirroring
738    /// the `Summarizing` provider's `with_summarizer` injection). `None`
739    /// (eval / headless) ⇒ the compression pipeline degrades to the static
740    /// "Summary generation was unavailable" marker instead of an LLM summary.
741    pub summarizer: Option<&'a SummarizeFn>,
742    /// Session-scoped compression anti-thrash state ([`CompressState`]),
743    /// owned by the caller across user turns and lent per call (the
744    /// `note_nudge` pattern). `None` ⇒ a fresh per-turn state.
745    pub compress_state: Option<&'a mut CompressState>,
746    /// Per-turn tool-event recorder (Step 17.6, #246): when present, the
747    /// loop pushes one [`crate::ToolEvent`] per executed tool call — name,
748    /// privacy-preserving args digest (never raw args), outcome, duration
749    /// claim — at the same site that renders tool activity live. The TUI
750    /// lends a fresh `Vec` per turn (the `note_nudge` pattern) and persists
751    /// it into the turn's `events` column. `None` (eval / headless) ⇒
752    /// nothing is recorded.
753    pub tool_events: Option<&'a mut Vec<crate::ToolEvent>>,
754    /// Per-turn phantom-reach recorder (#717): when present, the loop pushes one
755    /// [`crate::PhantomReach`] for each phantom tool/capability reach (alias
756    /// resolve, hallucination, or a real-tool empty-by-design miss). Lent fresh
757    /// per turn like `tool_events`; persisted into the turn's `phantom_reaches`.
758    /// `None` (eval / headless) ⇒ nothing recorded.
759    pub phantom_reaches: Option<&'a mut Vec<crate::PhantomReach>>,
760    /// Out-param: why the loop ended the turn ([`crate::TurnEndReason`]) —
761    /// narration-acceptance forensics, round cap, empty reply. Lent fresh per
762    /// turn like `tool_events`; the TUI folds it into `TurnMetrics` (footer +
763    /// usage.jsonl). `None` (eval / headless) ⇒ nothing reported. The
764    /// Responses-API loop does not report it.
765    pub end_reason: Option<&'a mut Option<crate::TurnEndReason>>,
766    /// Prompted ocap grants (issue #263): when present, a capability denial
767    /// inside `execute_tool` consults the human — allow once / session allow
768    /// / deny — instead of failing outright; the loop blocks like a long
769    /// tool call while the prompt is pending. `None` (the default — every
770    /// headless caller: ACP worker, `newt-eval`) keeps each denial exactly
771    /// as before, so nothing non-interactive can ever hang on a prompt.
772    pub permission_gate: Option<&'a mut dyn PermissionGate>,
773    /// Per-round capability observation hook (Phase 20,
774    /// `docs/design/model-self-tuning.md` §2.2): the loop reports each
775    /// round's evidence — accepted prompt sizes (with the matching chars/4
776    /// estimate for calibration), persistent-empty suspected overflows, the
777    /// thinking-only response quirk — at the moment of observation, so the
778    /// caller can persist it even when the turn later bails or errors (the
779    /// motivating failure discarded an accepted 8,734-token prompt because
780    /// the only write-back lived in the TUI's `Ok`-arm epilogue). `None`
781    /// (ACP worker, eval, cowork driver) preserves today's behavior exactly
782    /// (spec §5).
783    pub on_round_usage: Option<&'a mut dyn FnMut(RoundObservation)>,
784    /// Learned observed/estimated prompt-token ratio for this model
785    /// (Phase 20 §2.3), applied wherever chars/4 estimates meet
786    /// backend-reported token budgets — compression triggers, targets, and
787    /// the tool-schema overhead. `None` or out-of-clamp values degrade to
788    /// 1.0 (no calibration; pre-Phase-20 behavior).
789    pub estimate_ratio: Option<f32>,
790    /// `[context.estimation]` token-estimation heuristic (chars-per-token),
791    /// extracted from config so the loop never re-reads it — drives every
792    /// chars→token estimate and the budget→chars summary-cap conversion.
793    pub estimation: crate::tokens::TokenEstimation,
794    /// `[context] summary_input_cap_floor_chars` — floor for the summarizer
795    /// input cap so a tight budget never starves the summarizer of material.
796    pub summary_input_cap_floor_chars: usize,
797    /// `[context] input_ceiling_pct` — percent of `num_ctx` usable as input
798    /// before the reply reserve. Historically hardcoded at 80 (20% headroom);
799    /// large-window models (e.g. Opus) can safely raise this to pack more
800    /// context per turn. Applied by `num_ctx_input_ceiling`.
801    pub input_ceiling_pct: u32,
802    /// `[context] low_budget_pct` — remaining-budget percent below which the
803    /// loop treats the turn as "low budget" and nudges toward wrapping up.
804    /// Historically hardcoded at 15.
805    pub low_budget_pct: usize,
806    /// #307 named-permission-preset exec FLOOR. When a `/mode` preset is active
807    /// its exec clamp is threaded here so the `--disable-ocap` / `--yolo`
808    /// bypass in `execute_tool` cannot raise exec authority above the preset:
809    /// an out-of-floor command falls through to the confined shell and is
810    /// denied. `None` (no active preset, and every headless caller) leaves the
811    /// bypass bit-for-bit. The floor is also already `meet`-ed into `caveats`,
812    /// so the confined-shell and gate paths enforce it too; this field is the
813    /// one extra place the otherwise caveats-blind bypass must consult.
814    pub exec_floor: Option<&'a crate::caveats::Scope<String>>,
815    /// `retry` technique (R2 action arm): a turn-scoped copy-on-first-write ledger
816    /// ([`crate::verify_gate::WriteLedger`]) the file-write tools record into before
817    /// each `write_file` / `edit_file`, so the caller can revert exactly the files
818    /// newt wrote this turn (and only those) after the gate runs. Shared via
819    /// [`RefCell`](std::cell::RefCell) so the loop records while the caller reads.
820    /// `None` (every headless caller, and any profile without `retry`) ⇒ nothing is
821    /// recorded and no file is ever reverted — bit-for-bit today's behavior.
822    pub write_ledger: Option<&'a std::cell::RefCell<crate::verify_gate::WriteLedger>>,
823    /// User-interrupt flag (Esc / Ctrl-C during a turn). When set mid-turn the
824    /// loop abandons at its next checkpoint — the round-loop top, and the two
825    /// model awaits (the non-streaming probe and the token stream) — and
826    /// returns early. `None` (every headless / eval caller) ⇒ no interrupt
827    /// path, bit-for-bit today's behavior. The caller owns the `AtomicBool`,
828    /// trips it from a keyboard watcher, and inspects it after the call to tell
829    /// an interrupted turn from a genuinely empty reply.
830    pub cancel: Option<&'a std::sync::atomic::AtomicBool>,
831    /// The injected embedded-git capability (PR4, #461). `Some` ⇒ the `git`
832    /// tool is advertised and dispatches through it (`LocalGitTool` in
833    /// `newt-git`, injected by the binary). `None` (every headless / eval
834    /// caller, and any session not in a git repo) ⇒ the tool is never
835    /// advertised — bit-for-bit today's behavior. The trait-injection seam, not
836    /// a direct dep, because `newt-git` depends on `newt-core` (circular).
837    pub git_tool: Option<&'a dyn GitTool>,
838    /// The injected crew/team orchestration capability (#479). `Some` ⇒ the
839    /// `compose_roster` + `crew` tools are advertised and dispatch through it
840    /// (`LocalCrewRunner` in `newt-cli`, injected by the `/team` toggle). `None`
841    /// ⇒ never advertised. Trait-injection seam like `git_tool` (newt-scheduler
842    /// depends on newt-core, so the dep can't be direct).
843    pub crew_runner: Option<&'a dyn CrewRunner>,
844}
845
846/// retry technique (R2 action arm): before a `write_file`/`edit_file` is dispatched,
847/// capture the target's pre-write bytes into the turn ledger. Recorded at the loop's
848/// dispatch site (not inside `execute_tool`) so the seam stays narrow and only the
849/// two file-writing tools are ever tracked. [`WriteLedger::note_before_write`] is
850/// idempotent on the turn's first write of a path, so the *pre-turn* state is what
851/// is preserved. A no-op when no ledger is lent — every headless caller and any
852/// profile without `retry` — so behavior is bit-for-bit unchanged there.
853fn ledger_note_write(
854    write_ledger: Option<&std::cell::RefCell<crate::verify_gate::WriteLedger>>,
855    name: &str,
856    args: &serde_json::Value,
857    workspace: &str,
858) {
859    let Some(led) = write_ledger else {
860        return;
861    };
862    if name != "write_file" && name != "edit_file" {
863        return;
864    }
865    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
866        // Key on the lexically-normalized path so a raw model path like
867        // `examples/../foo.py` matches the gate's filesystem-normalized `foo.py`
868        // and the revert lookup actually hits — otherwise a non-normalized path
869        // would silently evade revert (the fabrication persists, the gate is gamed).
870        let abs = lexical_normalize(&std::path::Path::new(workspace).join(p));
871        led.borrow_mut().note_before_write(abs);
872    }
873}
874
875/// Lexically normalize a path — collapse `.`, resolve `..`, drop empty components —
876/// **without** touching the filesystem, so a ledger key built from a raw
877/// model-supplied path matches the gate's `read_dir`-normalized path. Purely
878/// lexical: it deliberately does not resolve symlinks (the gate never follows them
879/// and revert is workspace-boundary-guarded), so it cannot itself escape the tree.
880fn lexical_normalize(path: &std::path::Path) -> std::path::PathBuf {
881    use std::path::Component;
882    let mut out = std::path::PathBuf::new();
883    for comp in path.components() {
884        match comp {
885            Component::ParentDir => {
886                // Only pop a real path segment; never climb above a root/prefix.
887                if matches!(out.components().next_back(), Some(Component::Normal(_))) {
888                    out.pop();
889                } else {
890                    out.push("..");
891                }
892            }
893            Component::CurDir => {}
894            other => out.push(other.as_os_str()),
895        }
896    }
897    out
898}
899
900#[cfg(test)]
901mod retry_ledger_tests {
902    use super::*;
903
904    #[test]
905    fn lexical_normalize_collapses_dot_and_parent() {
906        assert_eq!(
907            lexical_normalize(std::path::Path::new("/ws/examples/../foo.py")),
908            std::path::PathBuf::from("/ws/foo.py")
909        );
910        assert_eq!(
911            lexical_normalize(std::path::Path::new("/ws/./a//b/foo.py")),
912            std::path::PathBuf::from("/ws/a/b/foo.py")
913        );
914        // A leading `..` with no segment to pop is preserved, not climbed past root.
915        assert_eq!(
916            lexical_normalize(std::path::Path::new("../x.py")),
917            std::path::PathBuf::from("../x.py")
918        );
919    }
920
921    #[test]
922    fn ledger_note_write_keys_on_the_normalized_path() {
923        let tmp = tempfile::tempdir().unwrap();
924        std::fs::write(tmp.path().join("foo.py"), "real\n").unwrap();
925        let led = std::cell::RefCell::new(crate::verify_gate::WriteLedger::new());
926        // a raw, non-normalized model path
927        let args = serde_json::json!({ "path": "examples/../foo.py" });
928        ledger_note_write(
929            Some(&led),
930            "write_file",
931            &args,
932            tmp.path().to_str().unwrap(),
933        );
934        // the key normalized to <ws>/foo.py — the same path the gate would produce —
935        // so revert finds and restores it (returns true).
936        assert!(
937            led.borrow().revert(&tmp.path().join("foo.py")).unwrap(),
938            "the normalized key matches the gate's path"
939        );
940        // a read-only tool is never recorded
941        ledger_note_write(
942            Some(&led),
943            "read_file",
944            &serde_json::json!({ "path": "foo.py" }),
945            tmp.path().to_str().unwrap(),
946        );
947        assert_eq!(led.borrow().len(), 1, "only write tools are tracked");
948    }
949}
950
951/// True when a backend 400 says the model can't accept a `tools` field.
952/// Ollama phrases it `"<model> does not support tools"`; OpenAI-compatible
953/// servers vary, so we also accept the looser `"not support tools"`. Used to
954/// drop tools and retry once, then keep them off for the turn (deepseek-r1).
955fn is_tools_unsupported_error(e: &anyhow::Error) -> bool {
956    let s = e.to_string().to_lowercase();
957    s.contains("does not support tools") || s.contains("not support tools")
958}
959
960/// True when Ollama accepted the `tools` field but its internal XML tool-call
961/// parser rejected the model's generated `<function>/<parameter>` markup before
962/// returning an assistant message. This is a malformed generation, not proof
963/// that the model lacks tool support, so retry with tools still advertised and
964/// a corrective nudge instead of disabling tools for the whole turn.
965fn is_ollama_tool_xml_error(e: &anyhow::Error) -> bool {
966    let s = e.to_string().to_lowercase();
967    s.contains("ollama") && s.contains("xml syntax error")
968}
969
970fn ollama_tool_xml_retry_nudge() -> &'static str {
971    "The previous assistant turn failed inside Ollama's XML tool-call parser. \
972     Keep using tools, but emit exactly one valid native tool call with well-formed \
973     arguments now. Do not answer in prose or wrap the call in explanatory XML."
974}
975
976/// Main agentic loop: call model → execute tool calls → feed results back → repeat.
977/// Returns `(reply_text, was_streamed, token_usage, hallucination_count)`.
978/// When `was_streamed` is true the text was already printed token-by-token.
979///
980/// Token-usage semantics (Step 18.1): `input_tokens` is the **largest single
981/// prompt** the backend evaluated across the turn's rounds — the truthful
982/// "how full did the context get" figure that feeds the capability ratchet —
983/// NOT the per-round sum, which double-counts history (every round's prompt
984/// re-includes all prior rounds; the B3 baseline measured 5.4× inflation).
985/// `output_tokens` is the sum across rounds (each completion is new).
986/// `true` once the caller's interrupt flag is set (Esc / Ctrl-C). Cheap relaxed
987/// load — the flag is a one-way latch, so no ordering guarantees are needed.
988fn is_cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) -> bool {
989    cancel.is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
990}
991
992/// Resolve as soon as the interrupt flag is set, polling at ~50 ms — fine for a
993/// human keypress and cheap enough to lose the race to any real I/O future.
994/// Never resolves when `cancel` is `None`, so a `select!` against it collapses
995/// to just the other arm (no behavior change for headless callers).
996async fn cancelled(cancel: Option<&std::sync::atomic::AtomicBool>) {
997    match cancel {
998        None => std::future::pending().await,
999        Some(flag) => {
1000            // Poll briskly so an interrupt is felt promptly (well under the
1001            // ~100 ms human threshold) — the cost is negligible next to a model
1002            // round-trip.
1003            while !flag.load(std::sync::atomic::Ordering::Relaxed) {
1004                tokio::time::sleep(std::time::Duration::from_millis(15)).await;
1005            }
1006        }
1007    }
1008}
1009
1010/// Race a model future against the interrupt flag: `Some(v)` if it finished,
1011/// `None` if the user interrupted first (the future is dropped, cancelling any
1012/// in-flight request).
1013async fn cancellable<F: std::future::Future>(
1014    cancel: Option<&std::sync::atomic::AtomicBool>,
1015    fut: F,
1016) -> Option<F::Output> {
1017    tokio::select! {
1018        biased;
1019        _ = cancelled(cancel) => None,
1020        v = fut => Some(v),
1021    }
1022}
1023
1024/// Drive `fut` while animating the thinking line in place (~8 fps), so a slow
1025/// non-streaming probe still feels alive — the braille/hourglass spinner
1026/// otherwise only advances when reasoning tokens arrive. Clears the line when
1027/// `fut` resolves so the following output starts clean. A no-op when `animate`
1028/// is false (piped / `thinking = "off"` / no TTY) — bit-for-bit today's path.
1029async fn with_thinking_spinner<F: std::future::Future>(
1030    animate: bool,
1031    label: &str,
1032    fut: F,
1033) -> F::Output {
1034    if !animate {
1035        return fut.await;
1036    }
1037    tokio::pin!(fut);
1038    let start = std::time::Instant::now();
1039    let mut frame = 0usize;
1040    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(120));
1041    loop {
1042        tokio::select! {
1043            biased;
1044            out = &mut fut => {
1045                let _ = write!(io::stdout(), "\r\x1b[K");
1046                let _ = io::stdout().flush();
1047                return out;
1048            }
1049            _ = ticker.tick() => {
1050                let line = format_spinner(frame, start.elapsed().as_secs_f32(), label, 0);
1051                let mut out = io::stdout();
1052                let _ = execute!(
1053                    out,
1054                    Print("\r\x1b[K"),
1055                    SetForegroundColor(CtColor::DarkGrey),
1056                    Print(&line),
1057                    ResetColor,
1058                );
1059                let _ = out.flush();
1060                frame = frame.wrapping_add(1);
1061            }
1062        }
1063    }
1064}
1065
1066pub async fn chat_complete(
1067    ctx: ChatCtx<'_>,
1068    mcp: &mut dyn McpTools,
1069) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
1070    // OpenAI-compatible endpoints speak a different wire format (request,
1071    // tool_calls, and usage shapes all differ), so they get their own loop.
1072    if ctx.kind == crate::BackendKind::Openai {
1073        // A backend with `api = "responses"` speaks the newer Responses API
1074        // (gpt-5-codex et al., served only there); the default stays on
1075        // /v1/chat/completions.
1076        if responses_api_selected() {
1077            return openai_responses_complete(ctx, mcp).await;
1078        }
1079        return openai_chat_complete(ctx, mcp).await;
1080    }
1081    // Step 25.4 (#568): capture the markdown decision before `ctx` is consumed
1082    // by the destructure (the destructures ignore it via `markdown: _`).
1083    let markdown = ctx.markdown;
1084    let ChatCtx {
1085        url,
1086        model,
1087        kind: _,
1088        api_key: _,
1089        messages: mem_messages,
1090        task,
1091        workspace,
1092        color,
1093        markdown: _,
1094        tool_offload,
1095        spill_store,
1096        compaction_store,
1097        scratchpad,
1098        scratchpad_store,
1099        code_search,
1100        experience_store,
1101        step_ledger,
1102        caveats,
1103        persona_tools,
1104        max_tool_rounds,
1105        workflow_grace_rounds,
1106        narration_nudge_cap,
1107        tool_output_lines,
1108        debug,
1109        trace,
1110        num_ctx,
1111        connect_timeout_secs,
1112        inference_timeout_secs,
1113        mid_loop_trim_threshold,
1114        mid_loop_trim_tokens,
1115        max_ok_input,
1116        build_check_cmd,
1117        safe_context,
1118        recover_cw_400,
1119        mut note_sink,
1120        mut note_nudge,
1121        recall_source,
1122        memory_source,
1123        summarizer,
1124        compress_state,
1125        mut tool_events,
1126        mut phantom_reaches,
1127        mut end_reason,
1128        mut permission_gate,
1129        mut on_round_usage,
1130        estimate_ratio,
1131        estimation,
1132        summary_input_cap_floor_chars,
1133        input_ceiling_pct,
1134        low_budget_pct,
1135        exec_floor,
1136        write_ledger,
1137        cancel,
1138        git_tool,
1139        crew_runner,
1140    } = ctx;
1141    // Headless callers may pass no session state — compression still works,
1142    // with per-turn anti-thrash accounting.
1143    let mut local_compress_state = CompressState::new();
1144    let compress_state = match compress_state {
1145        Some(s) => s,
1146        None => &mut local_compress_state,
1147    };
1148    let client = reqwest::Client::builder()
1149        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1150        .timeout(std::time::Duration::from_secs(inference_timeout_secs))
1151        .build()?;
1152    // #643: the streaming re-issue (the final-text round consumed token-by-token
1153    // by `stream_response`) must NOT use a whole-request `.timeout()`. That bounds
1154    // connect + headers + the ENTIRE body, so a slow-but-progressing token stream
1155    // is aborted mid-flight the instant total time crosses the deadline, and the
1156    // retry envelope then restarts the full prefill — the DGX retry-storm wedge.
1157    // An IDLE `read_timeout` is the right bound: it caps the gap between chunks and
1158    // resets on every token, so a progressing stream runs as long as it keeps
1159    // producing while a genuinely stalled connection still bails after
1160    // `inference_timeout_secs` of silence. The one-shot `stream:false` probe below
1161    // keeps `client` (a whole-request bound is correct for a single-shot response).
1162    let stream_client = reqwest::Client::builder()
1163        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
1164        .read_timeout(std::time::Duration::from_secs(inference_timeout_secs))
1165        .build()?;
1166    let chat_url = format!("{}/api/chat", url.trim_end_matches('/'));
1167    let retry = tui_retry_policy();
1168    // The save_note tool is advertised only when a sink exists (Step 19.3);
1169    // recall only when a source exists (Step 17.5); memory_fetch only when a
1170    // memory source exists (#319) — same presence gating.
1171    let advertise_save_note = note_sink.is_some();
1172    let advertise_recall = recall_source.is_some();
1173    let advertise_memory_fetch = memory_source.is_some();
1174    // Step 26.4 (#583): state tools only when the feature is on AND a store exists.
1175    let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
1176    // Step 26.5.5 (#582): the code_search tool when a searcher is present.
1177    let advertise_code_search = code_search.is_some();
1178    // Step 26.6a (#585): the experiential tools when a store is present.
1179    let advertise_experiential = experience_store.is_some();
1180    // Step 26.6b (#586): the scheduled plan tools when a ledger is present.
1181    let advertise_scheduled = step_ledger.is_some();
1182    let advertise_git = git_tool.is_some();
1183    let advertise_team = crew_runner.is_some();
1184
1185    // Convert MemMessage list to Ollama JSON format.
1186    // The memory manager already included the current task as the last user message.
1187    let mut messages: Vec<serde_json::Value> = mem_messages
1188        .iter()
1189        .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
1190        .collect();
1191
1192    // In-band memory nudge (Step 19.3): after `[memory] note_nudge_interval`
1193    // user turns with zero organic save_note use, append a one-line reminder
1194    // to this turn's user message. Only when a sink exists — without one the
1195    // save_note tool isn't even advertised.
1196    if note_sink.is_some() {
1197        if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
1198            append_nudge_line(&mut messages, &line);
1199        }
1200    }
1201
1202    let mut accumulated_usage: Option<crate::TokenUsage> = None;
1203    let mut hallucination_count: u32 = 0;
1204    // Step 27.3/#771: guard against exact-repeat tool loops this run.
1205    let mut repeat_calls = RepeatCallGuard::default();
1206    let mut overflow_retries: u32 = 0;
1207    let mut suspicious_empty_retries: u32 = 0;
1208    // Hard context-window 400s recovered (parse limit → trim → retry). See #223.
1209    let mut cw_retries: u32 = 0;
1210    // Some models reject ANY request carrying a `tools` field (e.g.
1211    // deepseek-r1). Once one 400s with "does not support tools", drop tools for
1212    // the rest of the session so even a bare "hello" works; notice it once.
1213    let mut tools_supported = true;
1214    let mut tools_unsupported_notified = false;
1215    // Pre-send token budget gate: trim before dispatch when the current context
1216    // size exceeds the model's empirically-confirmed max input (or the safe
1217    // context) — capped by the `num_ctx` every request this turn will carry
1218    // (#282: on a fresh capability cache the cached numbers are unset or huge,
1219    // so without the ceiling the first turn dispatched 10× over the real
1220    // window with zero events — B6). Mutable because a recovered 400 tightens
1221    // it mid-turn. See #223.
1222    let num_ctx_ceiling = num_ctx_input_ceiling(num_ctx, input_ceiling_pct);
1223    let mut send_budget: Option<usize> =
1224        initial_send_budget(max_ok_input, safe_context, num_ctx, input_ceiling_pct);
1225    // Step 20.3: is the send budget backed by an authoritative ceiling, or
1226    // does it rest on the proven-good high-water mark (`max_ok_input`) alone?
1227    // `safe_context` (a believed/declared window) and the per-request
1228    // `num_ctx` ceiling are authoritative; a cw-400 recovery flips this true
1229    // mid-turn. Cloud endpoints with no `/api/show` seed neither, so their
1230    // guard is non-authoritative and fails open instead of refusing.
1231    let mut send_budget_authoritative = safe_context.is_some() || num_ctx_ceiling.is_some();
1232    // Tool schemas ride along in every request body; count them once (18.1).
1233    // Stable for the whole turn: the builtin + MCP tool set doesn't change
1234    // mid-turn, so hoisting out of the round loop is safe.
1235    let tools = merged_tool_definitions(
1236        mcp,
1237        advertise_save_note,
1238        advertise_recall,
1239        advertise_memory_fetch,
1240        advertise_git,
1241        advertise_team,
1242        advertise_scratchpad,
1243        advertise_code_search,
1244        advertise_experiential,
1245        advertise_scheduled,
1246    );
1247    // FR-1 part 2 (#997): scope the advertised catalog to the active persona's
1248    // `tools:` allow-list (no-op when `persona_tools` is `None`). The executor
1249    // enforces the same set, so what the model sees and what it may run agree.
1250    let tools = filter_advertised_tools(tools, persona_tools);
1251    let tool_tokens = estimate_value_tokens(&tools, estimation);
1252    // Phase 20 §2.3: one sanitized calibration ratio per turn. The
1253    // tool-schema overhead converts to real-token space once — the schema
1254    // set is stable for the whole turn, and the send budget it is subtracted
1255    // from is real-token currency.
1256    let cal = sanitize_estimate_ratio(estimate_ratio);
1257    let tool_tokens_real = calibrate_up(tool_tokens, cal);
1258    // Animate the in-place "thinking…/compressing…" status line only on a TTY
1259    // with streaming enabled (never in a pipe / `newt worker`). Constant for the
1260    // turn — both the compression and probe waits reuse it.
1261    let animate = color && thinking_stream_enabled();
1262    // Truthful context-size tracker: anchors on the backend's last-reported
1263    // prompt token count, chars/4 + schema estimate as fallback (Step 18.1).
1264    let mut prompt_tracker = PromptTracker::new();
1265    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1266    // Consecutive rounds where the model only called read-only tools (no writes).
1267    // When this hits READ_ONLY_NUDGE_AFTER, a brief injected message tells the
1268    // model to stop exploring and start writing.
1269    let mut read_only_rounds: usize = 0;
1270    // "Narrate-then-stop" rescue: a weak model often ANNOUNCES its next action
1271    // in prose ("Let me edit …") and emits no tool call. The loop would treat
1272    // that zero-tool round as a final answer and end the turn, forcing a human
1273    // "continue". `narration_nudges` bounds the auto-continue that instead
1274    // nudges the model to actually call the tool (≤ `narration_nudge_cap` per
1275    // turn); the trigger is the configurable NudgeClassifier, so a genuine
1276    // conclusion (with or without prior tool calls this turn) is never nudged.
1277    let mut narration_nudges: usize = 0;
1278    // State-driven final-answer gate: a no-tool reply is suspicious when the
1279    // active plan still has open steps. Nudge once to update_plan / act / block.
1280    let mut pending_plan_nudges: usize = 0;
1281    // A stricter sibling: when a model concludes "the file changed under me"
1282    // without proving it, force one read-only ground-truth check round instead
1283    // of letting it hand the stale-context claim back to the human.
1284    let mut stale_file_nudges: usize = 0;
1285    let nudge_classifier = crate::NudgeClassifier::load_default();
1286    let workflow_steerer = crate::WorkflowSteerer::load_default();
1287    let mut workflow_runtime = WorkflowRuntimeState::default();
1288    // The matching workflow's round-cap grace horizon override, resolved once
1289    // from the turn's opening context (diagnostic workflows need more
1290    // read-only rounds between checkpoints than routine edits — see
1291    // `diagnose_failure.toml`'s `progress_horizon_rounds`).
1292    workflow_runtime.set_progress_horizon(
1293        workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
1294    );
1295    let mut ollama_xml_retry_nudges: usize = 0;
1296    // Phase 20 §2.2: the thinking-only quirk is reported at most once per
1297    // turn — re-detection adds no information and would thrash the cache.
1298    let mut thinking_only_reported = false;
1299    // #867 Part A: ledger of REAL workspace paths surfaced by tool results,
1300    // collected as the rounds happen so it survives the cap-exit trim.
1301    let mut observed_paths = claim_check::ObservedPaths::default();
1302    let observed_resolver = claim_check::workspace_resolver(workspace);
1303
1304    // Agentic loop — up to `max_tool_rounds` tool-call rounds, with an optional
1305    // evidence-backed grace window when the normal cap lands during active
1306    // workflow progress. The hard ceiling remains finite and configurable.
1307    let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
1308    let mut workflow_grace_active = false;
1309    let mut current_tool_round_limit = max_tool_rounds;
1310    'round_loop: for round in 0..hard_tool_rounds {
1311        if round >= current_tool_round_limit {
1312            if workflow_grace_active {
1313                break;
1314            }
1315            let Some(nudge) = workflow_runtime.cap_grace_nudge(
1316                step_ledger,
1317                max_tool_rounds,
1318                workflow_grace_rounds,
1319            ) else {
1320                break;
1321            };
1322            workflow_grace_active = true;
1323            current_tool_round_limit = hard_tool_rounds;
1324            if debug {
1325                print_debug(
1326                    "workflow progress at soft round cap — granting configured grace window",
1327                    color,
1328                );
1329            }
1330            messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1331        }
1332        // Interrupt checkpoint (Esc / Ctrl-C): bail before spending another
1333        // round on the model or a tool. The reply is empty — the caller sees
1334        // `cancel` set and treats the turn as abandoned regardless.
1335        if is_cancelled(cancel) {
1336            return Ok((String::new(), false, accumulated_usage, hallucination_count));
1337        }
1338        if round > 0 {
1339            // Brief separator between rounds so user can follow the flow.
1340            if color {
1341                execute!(
1342                    io::stdout(),
1343                    SetForegroundColor(CtColor::DarkGrey),
1344                    Print("…\n"),
1345                    ResetColor
1346                )
1347                .ok();
1348            }
1349        }
1350
1351        // Conditional plan re-seat (#630 b): re-show the ACTIVE step each round
1352        // so a weak model doesn't lose track of a multi-step plan as the round-0
1353        // <plan> snapshot goes stale (validated on dgx1: re-seat 12/12 vs baseline
1354        // 8/12 under drift). Compact + gated to multi-step in-progress plans.
1355        // Supersedes the env-gated NEWT_RESEAT_PLAN experiment that #629 carried
1356        // to main by accident.
1357        if round > 0 {
1358            if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
1359                messages.push(serde_json::json!({ "role": "user", "content": ptr }));
1360            }
1361            if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
1362                messages.push(serde_json::json!({ "role": "user", "content": nudge }));
1363            }
1364        }
1365
1366        // Read-only round nudge: if the model has spent several consecutive
1367        // rounds only reading (list_dir / read_file / web_fetch / search /
1368        // use_skill) without writing anything, inject a brief reminder to
1369        // stop exploring and call edit_file or write_file.  This breaks the
1370        // "endless exploration → empty response" failure mode seen with some
1371        // local models (e.g. nemotron3:33b).
1372        const READ_ONLY_NUDGE_AFTER: usize = 3;
1373        if read_only_rounds >= READ_ONLY_NUDGE_AFTER {
1374            let remaining = current_tool_round_limit.saturating_sub(round + 1);
1375            // Sustained read-only exploration on a task that classifies as a
1376            // diagnose/fix workflow is exactly the shape `crew`/`team`
1377            // delegation exists for — offer it here (only when sub-agent
1378            // dispatch is actually available this session) instead of only
1379            // ever telling the model to act inline.
1380            let delegate_hint = workflow_steerer
1381                .delegate_hint(&workflow_classifier_text(&messages, ""), advertise_team);
1382            messages.push(serde_json::json!({
1383                "role": "user",
1384                "content": read_only_action_nudge(
1385                    read_only_rounds,
1386                    remaining,
1387                    step_ledger,
1388                    delegate_hint.as_deref(),
1389                )
1390            }));
1391            read_only_rounds = 0;
1392        }
1393
1394        // Context compression (Step 18.4, #247): one shared pipeline —
1395        // structural prune → boundary → redacted LLM summary → marker
1396        // assembly — serves both the mid-loop trigger (message count OR
1397        // current tokens: the VRAM guard and issue #223's token guard) and
1398        // the pre-send budget guard (`max_ok_input`/`safe_context`). The
1399        // current-token figure is prompt-tokens-preferred (Step 18.1). The
1400        // old amputation trim survives only as the pipeline's no-summarizer
1401        // static-marker path.
1402        {
1403            // Phase 20 §2.3: `current` is calibrated into real-token space —
1404            // the same currency as the (backend-derived) send budget and the
1405            // configured token threshold it is compared against.
1406            let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
1407            // The count-only budget is priced in message-token space — the
1408            // same chars/4 currency the pipeline compares its budget against
1409            // (F1); `current` (schema/template-inclusive) still drives the
1410            // token triggers.
1411            let message_tokens = estimate_tokens(&messages, estimation);
1412            if let Some(trigger) = compression_trigger(
1413                messages.len(),
1414                current,
1415                message_tokens,
1416                mid_loop_trim_threshold,
1417                mid_loop_trim_tokens,
1418                send_budget,
1419                tool_tokens_real,
1420            ) {
1421                // A hard trigger's budget is real-token currency; the
1422                // pipeline measures and reclaims in chars/4 — convert once
1423                // (Phase 20 §2.3). Count-only budgets are already priced in
1424                // message-token space (F1) and pass through unconverted.
1425                let pipeline_budget = if trigger.hard_budget {
1426                    calibrate_down(trigger.budget, cal)
1427                } else {
1428                    trigger.budget
1429                };
1430                // Step 20.3: does this budget rest on an authoritative ceiling
1431                // or the lone proven-good HWM? A fired token threshold is
1432                // user-authoritative; otherwise the guard fired, authoritative
1433                // only when the send budget is backed by a believed window.
1434                let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
1435                // Compression makes its own summarizer model call — animate the
1436                // line with a "compressing context…" stage so it doesn't sit
1437                // frozen, and race it against the interrupt flag so Esc bails
1438                // out of a slow summarize instead of waiting for it to finish.
1439                let outcome = match with_thinking_spinner(
1440                    animate,
1441                    "compressing context…",
1442                    cancellable(
1443                        cancel,
1444                        compress(
1445                            CompressRequest {
1446                                messages: &messages,
1447                                budget: pipeline_budget,
1448                                max_messages: trigger.max_messages,
1449                                task,
1450                                hard_budget: trigger.hard_budget,
1451                                authoritative: token_fired || send_budget_authoritative,
1452                                focus: None,
1453                                est: estimation,
1454                                summary_input_cap_floor_chars,
1455                                compaction_store,
1456                            },
1457                            summarizer,
1458                            compress_state,
1459                        ),
1460                    ),
1461                )
1462                .await
1463                {
1464                    Some(o) => o,
1465                    None => {
1466                        return Ok((String::new(), false, accumulated_usage, hallucination_count))
1467                    }
1468                };
1469                if let Some(notice) = outcome.notice {
1470                    print_harness_notice(&notice, color);
1471                }
1472                if outcome.action == CompressAction::Refused {
1473                    // Anti-thrash disabled compression and the context still
1474                    // exceeds the budget: refuse the send rather than let the
1475                    // backend silently truncate the task away (baseline B6).
1476                    // Phase 20: name the model and the reset escape hatch —
1477                    // a poisoned learned budget is a known cause of this bail.
1478                    anyhow::bail!(
1479                        "context (~{current} tokens) exceeds the model's input budget and \
1480                         auto-compression is disabled after repeated ineffective passes — \
1481                         start a new conversation or ask a more focused question, or run \
1482                         `newt tunings reset {model}` if this model's learned budget looks wrong"
1483                    );
1484                }
1485                if outcome.fired {
1486                    // N2: a hard-budget compression whose assembled result is
1487                    // still over budget says so — the dispatch proceeds (the
1488                    // cw-400/overflow recovery bounds it), but visibly. The
1489                    // comparison is in the SAME (chars/4) currency the
1490                    // pipeline measured `tokens_after` in (Phase 20 §2.3).
1491                    let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
1492                        ", still over budget"
1493                    } else {
1494                        ""
1495                    };
1496                    emit_compression_notice(
1497                        color,
1498                        outcome.tokens_before,
1499                        outcome.tokens_after,
1500                        outcome.action,
1501                        suffix,
1502                    );
1503                    if debug {
1504                        print_debug(
1505                            &format!(
1506                                "compression: {} → {} messages (budget ~{} tokens, \
1507                                 +~{tool_tokens} tool-schema tokens ride along)",
1508                                messages.len(),
1509                                outcome.messages.len(),
1510                                pipeline_budget,
1511                            ),
1512                            color,
1513                        );
1514                    }
1515                    messages = outcome.messages;
1516                    prompt_tracker.invalidate();
1517                    apply_post_compaction_continuation(
1518                        &mut messages,
1519                        &mut narration_nudges,
1520                        outcome.action,
1521                        step_ledger,
1522                        round > 0,
1523                    );
1524                }
1525            }
1526        }
1527
1528        // Phase 20 §2.2: chars/4 estimate of EXACTLY the request about to be
1529        // dispatched (the message list as sent, plus tool schemas) — paired
1530        // with the backend's reported prompt size in the `Accepted`
1531        // observation so the caller can learn the calibration ratio.
1532        let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
1533
1534        // Tool-call rounds: stream:false (fast, just JSON).
1535        // Final text round: stream:true so the user sees tokens arrive.
1536        // We don't know which round is last, so we probe with stream:false first
1537        // and switch to streaming only when the model returns no tool calls.
1538        let mut body_no_stream = if let Some(ctx_size) = num_ctx {
1539            serde_json::json!({
1540                "model": model,
1541                "messages": messages,
1542                "stream": false,
1543                "tools": tools.clone(),
1544                "options": { "num_ctx": ctx_size },
1545            })
1546        } else {
1547            serde_json::json!({
1548                "model": model,
1549                "messages": messages,
1550                "stream": false,
1551                "tools": tools.clone(),
1552            })
1553        };
1554        // Drop tools entirely for a model that rejects them (set below on a
1555        // "does not support tools" 400) — an empty array still trips strict
1556        // models, so remove the key.
1557        if !tools_supported {
1558            if let Some(o) = body_no_stream.as_object_mut() {
1559                o.remove("tools");
1560            }
1561        }
1562
1563        // Retry the send+status+parse as one unit — a connection drop at any
1564        // of these steps is transient and worth retrying with backoff. Raced
1565        // against the interrupt flag so Esc bails out of a slow / stuck probe
1566        // (the common "the model isn't answering" case) without waiting for it.
1567        // Wrapped in the thinking animation so this otherwise-silent wait shows
1568        // a live hourglass + clock instead of a frozen line.
1569        let dispatch = match with_thinking_spinner(
1570            animate,
1571            "thinking…",
1572            cancellable(
1573                cancel,
1574                with_backoff_notify(
1575                    &retry,
1576                    || async {
1577                        let resp = client
1578                            .post(&chat_url)
1579                            .json(&body_no_stream)
1580                            .send()
1581                            .await
1582                            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
1583                        if !resp.status().is_success() {
1584                            let status = resp.status();
1585                            let text = resp.text().await.unwrap_or_default();
1586                            anyhow::bail!("Ollama {status}: {text}");
1587                        }
1588                        resp.json::<serde_json::Value>()
1589                            .await
1590                            .map_err(anyhow::Error::from)
1591                    },
1592                    |attempt, delay| print_retry_indicator(attempt, delay, color),
1593                ),
1594            ),
1595        )
1596        .await
1597        {
1598            Some(d) => d,
1599            // Interrupted mid-probe: abandon the turn.
1600            None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
1601        };
1602        let json: serde_json::Value = match dispatch {
1603            Ok(j) => j,
1604            Err(e) => {
1605                // No-tools recovery: a model that rejects the `tools` field
1606                // (deepseek-r1) 400s even on "hello". Drop tools, notice once,
1607                // and re-dispatch the same turn — self-limiting because the
1608                // rebuilt body omits tools. A malformed XML tool-call error is
1609                // different: Ollama accepted tools but choked on the model's
1610                // generated markup, so keep tools available and retry with a
1611                // bounded corrective nudge.
1612                let tools_unsupported = is_tools_unsupported_error(&e);
1613                let malformed_xml_tool_call = is_ollama_tool_xml_error(&e);
1614                if tools_supported && tools_unsupported {
1615                    tools_supported = false;
1616                    if !tools_unsupported_notified {
1617                        tools_unsupported_notified = true;
1618                        let notice = format!(
1619                            "{model} does not support tools — tools disabled for this turn"
1620                        );
1621                        print_newt(&notice, color, false);
1622                    }
1623                    continue 'round_loop;
1624                }
1625                if tools_supported && malformed_xml_tool_call && ollama_xml_retry_nudges < 2 {
1626                    ollama_xml_retry_nudges += 1;
1627                    print_newt(
1628                        &format!(
1629                            "{model} produced malformed Ollama XML tool-call syntax — \
1630                             retrying with a stricter tool-call nudge"
1631                        ),
1632                        color,
1633                        false,
1634                    );
1635                    messages.push(serde_json::json!({
1636                        "role": "user",
1637                        "content": ollama_tool_xml_retry_nudge()
1638                    }));
1639                    continue 'round_loop;
1640                }
1641                // Graceful context-window 400 recovery: parse the model's real
1642                // limit, tighten the budget, compress, and retry once (#223;
1643                // compress-not-trim since Step 18.4).
1644                if cw_retries < 2 {
1645                    if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
1646                        emit_overflow_notice(
1647                            color,
1648                            accumulated_usage.as_ref(),
1649                            Some(new_cap),
1650                            model,
1651                            cw_retries + 1,
1652                        );
1653                        // A recovered cap can only tighten — the request still
1654                        // carries the same `num_ctx`, so its ceiling holds (#282).
1655                        let new_budget =
1656                            num_ctx_ceiling.map_or(new_cap as usize, |c| (new_cap as usize).min(c));
1657                        send_budget = Some(new_budget);
1658                        // The endpoint's parsed hard limit is authoritative —
1659                        // a refuse on it is correct from here on (Step 20.3).
1660                        send_budget_authoritative = true;
1661                        let outcome = compress(
1662                            CompressRequest {
1663                                // Real-token budget minus real-token schema
1664                                // overhead, converted into the pipeline's
1665                                // chars/4 currency (Phase 20 §2.3).
1666                                messages: &messages,
1667                                budget: calibrate_down(
1668                                    new_budget.saturating_sub(tool_tokens_real),
1669                                    cal,
1670                                ),
1671                                max_messages: None,
1672                                task,
1673                                hard_budget: true,
1674                                authoritative: true,
1675                                focus: None,
1676                                est: estimation,
1677                                summary_input_cap_floor_chars,
1678                                compaction_store,
1679                            },
1680                            summarizer,
1681                            compress_state,
1682                        )
1683                        .await;
1684                        if let Some(notice) = outcome.notice {
1685                            print_harness_notice(&notice, color);
1686                        }
1687                        if outcome.action == CompressAction::Refused {
1688                            // Refuse the resend; surface the endpoint's 400.
1689                            return Err(e);
1690                        }
1691                        if outcome.fired {
1692                            messages = outcome.messages;
1693                            prompt_tracker.invalidate();
1694                            apply_post_compaction_continuation(
1695                                &mut messages,
1696                                &mut narration_nudges,
1697                                outcome.action,
1698                                step_ledger,
1699                                round > 0,
1700                            );
1701                        }
1702                        cw_retries += 1;
1703                        continue 'round_loop;
1704                    }
1705                }
1706                return Err(e);
1707            }
1708        };
1709
1710        // Merge token usage from this non-streaming probe round (input = max
1711        // single prompt, output = sum — Step 18.1) and anchor the context-size
1712        // tracker on the backend-reported prompt size of this dispatch.
1713        let round_usage = ollama_usage(&json);
1714        if let Some(u) = round_usage {
1715            prompt_tracker.record(u.input_tokens, messages.len());
1716        }
1717        accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
1718
1719        // Phase 20 §2.2: a prompt within 5% of the request's `num_ctx` may
1720        // have been silently head-truncated by Ollama — such a round is
1721        // window evidence of NOTHING and must neither raise the budget nor
1722        // emit an `Accepted` observation.
1723        let truncation_suspect = round_usage
1724            .is_some_and(|u| num_ctx.is_some_and(|c| u.input_tokens >= c.saturating_mul(95) / 100));
1725        // Mid-turn budget raise on window evidence alone: the backend just
1726        // evaluated this many prompt tokens inside the `num_ctx` it was sent,
1727        // so one over-budget acceptance stops the compress-every-round thrash
1728        // within the same turn (Phase 20 §2.2). Never lowers; stays under the
1729        // per-request input ceiling.
1730        if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
1731            let raised = (u.input_tokens as usize).min(num_ctx_ceiling.unwrap_or(usize::MAX));
1732            if raised > budget {
1733                send_budget = Some(raised);
1734                if debug {
1735                    print_debug(
1736                        &format!(
1737                            "send budget raised to ~{raised} tokens (backend accepted \
1738                             {}-token prompt)",
1739                            u.input_tokens
1740                        ),
1741                        color,
1742                    );
1743                }
1744            }
1745        }
1746
1747        let message = &json["message"];
1748        // Capture the probe content now — it may be our only copy of the
1749        // model's reply if the subsequent streaming re-issue returns empty.
1750        let probe_content = message["content"].as_str().unwrap_or("").to_string();
1751
1752        let native_calls = message["tool_calls"].as_array();
1753        // Recover tool calls a weak model emitted in CONTENT instead of the
1754        // native `tool_calls` field — the #1 weak-model failure (see
1755        // `tool_recovery`). Only attempted when the native array is empty;
1756        // recovered calls are produced in native shape and flow unchanged into
1757        // the executor + `is_hallucination` + dup-guard + caveat path below.
1758        let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
1759            tool_recovery::recover_tool_calls(&probe_content)
1760        } else {
1761            tool_recovery::Recovery::default()
1762        };
1763        let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
1764            Some(t) if !t.is_empty() => Some(t),
1765            _ if !recovered.calls.is_empty() => Some(&recovered.calls),
1766            _ => None,
1767        };
1768        let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
1769        if debug && !recovered.calls.is_empty() {
1770            print_debug(
1771                &format!(
1772                    "recovered {} tool call(s) from content (non-native emission)",
1773                    recovered.calls.len()
1774                ),
1775                color,
1776            );
1777        }
1778
1779        if debug {
1780            let content_excerpt = if probe_content.is_empty() {
1781                "(empty)".to_string()
1782            } else {
1783                let chars: String = probe_content.chars().take(80).collect();
1784                if probe_content.len() > 80 {
1785                    format!("{chars}…")
1786                } else {
1787                    chars
1788                }
1789            };
1790            let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
1791            let usage_str = match round_usage {
1792                Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
1793                None => "no usage".into(),
1794            };
1795            print_debug(
1796                &format!(
1797                    "round {round} probe: tool_calls={tc_count} usage=[{usage_str}] content={content_excerpt:?}"
1798                ),
1799                color,
1800            );
1801        }
1802
1803        if !has_tools {
1804            // Format-hallucination tracker: the content looked like a tool-call
1805            // attempt but could not be recovered into one — count it so cap-exit
1806            // and metrics see a tooling failure, not a clean final answer.
1807            if recovered.tool_shaped {
1808                hallucination_count += 1;
1809                if debug {
1810                    print_debug(
1811                        "format-hallucination: tool call emitted as unrecoverable text",
1812                        color,
1813                    );
1814                }
1815            }
1816            // A final text candidate can come from either the streaming re-issue
1817            // or the non-streamed probe fallback. Run both through the same
1818            // no-tool final-answer gates so "Let me inspect..." does not force a
1819            // human "continue" just because the stream returned empty.
1820            macro_rules! maybe_nudge_no_tool_content {
1821                ($content:expr, $usage:expr) => {{
1822                    let content = $content;
1823                    if !content.is_empty() {
1824                        let nudge_classification = nudge_classifier.classify(content);
1825                        let workflow_classifier_text =
1826                            workflow_classifier_text(&messages, content);
1827                        let workflow_hint = nudge_classification
1828                            .is_plan_update()
1829                            .then(|| workflow_steerer.plan_update_hint(&workflow_classifier_text))
1830                            .flatten();
1831                        let classifier_plan_direction = nudge_classification
1832                            .is_plan_update()
1833                            .then(|| {
1834                                nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate)
1835                            })
1836                            .flatten();
1837                        let plan_nudge_hint =
1838                            combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
1839                        if round + 1 < current_tool_round_limit {
1840                            if let Some(nudge) = workflow_runtime.rediscovery_nudge(
1841                                Some(&nudge_classification),
1842                                content,
1843                                step_ledger,
1844                            ) {
1845                                if debug {
1846                                    print_debug(
1847                                        "workflow evidence rediscovery — nudging toward active repair",
1848                                        color,
1849                                    );
1850                                }
1851                                messages.push(serde_json::json!({
1852                                    "role": "assistant",
1853                                    "content": content
1854                                }));
1855                                messages.push(serde_json::json!({
1856                                    "role": "user",
1857                                    "content": format!(
1858                                        "{} {}",
1859                                        compress::LOOP_GUIDANCE_PREFIX, nudge
1860                                    )
1861                                }));
1862                                accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1863                                continue 'round_loop;
1864                            }
1865                        }
1866                        if pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
1867                            && round + 1 < current_tool_round_limit
1868                        {
1869                            if let Some(nudge) = pending_plan_completion_nudge(
1870                                step_ledger,
1871                                nudge_classification.is_plan_update(),
1872                                plan_nudge_hint.as_deref(),
1873                            ) {
1874                                if debug {
1875                                    print_debug(
1876                                        "active plan has unfinished steps — nudging before final answer",
1877                                        color,
1878                                    );
1879                                }
1880                                messages.push(serde_json::json!({
1881                                    "role": "assistant",
1882                                    "content": content
1883                                }));
1884                                messages.push(serde_json::json!({
1885                                    "role": "user",
1886                                    "content": format!(
1887                                        "{} {}",
1888                                        compress::LOOP_GUIDANCE_PREFIX, nudge
1889                                    )
1890                                }));
1891                                pending_plan_nudges += 1;
1892                                accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1893                                continue 'round_loop;
1894                            }
1895                        }
1896                        if stale_file_nudges < STALE_FILE_NUDGE_CAP
1897                            && round + 1 < current_tool_round_limit
1898                            && looks_like_unverified_stale_file_blocker(content)
1899                        {
1900                            if debug {
1901                                print_debug(
1902                                    "unverified stale-file blocker — nudging to check ground truth",
1903                                    color,
1904                                );
1905                            }
1906                            messages.push(serde_json::json!({
1907                                "role": "assistant",
1908                                "content": content
1909                            }));
1910                            messages.push(serde_json::json!({
1911                                "role": "user",
1912                                "content": format!(
1913                        "{} {}",
1914                        compress::LOOP_GUIDANCE_PREFIX,
1915                        stale_file_ground_truth_nudge()
1916                    ),
1917                            }));
1918                            stale_file_nudges += 1;
1919                            accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1920                            continue 'round_loop;
1921                        }
1922                        if narration_nudges < narration_nudge_cap
1923                            && round + 1 < current_tool_round_limit
1924                            && nudge_classification.is_pending_action()
1925                        {
1926                            if debug {
1927                                print_debug(
1928                                    "narrated intent with no tool call — nudging to act and continuing",
1929                                    color,
1930                                );
1931                            }
1932                            // Record the model's own narration, then the
1933                            // corrective, so the next round sees both (mirrors
1934                            // the has-tools assistant turn).
1935                            messages.push(serde_json::json!({
1936                                "role": "assistant",
1937                                "content": content
1938                            }));
1939                            // First nudge: the (tunable) classifier direction.
1940                            // Later nudges (cap > 1): generic text already
1941                            // failed to convert intent into action, so
1942                            // escalate — name the active step, demand a bare
1943                            // tool call.
1944                            let direction = if narration_nudges == 0 {
1945                                nudge_classifier
1946                                    .direction_for(nudge_classification.class)
1947                                    .map(str::to_string)
1948                                    .unwrap_or_else(narration_action_nudge)
1949                            } else {
1950                                escalated_narration_action_nudge(
1951                                    narration_nudges + 1,
1952                                    narration_nudge_cap,
1953                                    step_ledger,
1954                                )
1955                            };
1956                            messages.push(serde_json::json!({
1957                                "role": "user",
1958                                "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, direction),
1959                            }));
1960                            narration_nudges += 1;
1961                            accumulated_usage = merge_round_usage(accumulated_usage, $usage);
1962                            continue 'round_loop;
1963                        }
1964                        // Every rescue gate passed on this content: it is
1965                        // about to be accepted as the final answer. Record
1966                        // WHY — before this, a narration acceptance was
1967                        // indistinguishable from a normal completion, even
1968                        // under NEWT_DEBUG (2026-07-08 ornith:35b forensics).
1969                        let accepted_reason = if nudge_classification.is_pending_action() {
1970                            if round + 1 >= current_tool_round_limit {
1971                                crate::TurnEndReason::NarrationFinalRound
1972                            } else {
1973                                crate::TurnEndReason::NarrationCapExhausted
1974                            }
1975                        } else {
1976                            crate::TurnEndReason::Completed
1977                        };
1978                        if debug && accepted_reason != crate::TurnEndReason::Completed {
1979                            print_debug(
1980                                &format!(
1981                                    "no-tool narration accepted as final answer ({accepted_reason:?})"
1982                                ),
1983                                color,
1984                            );
1985                        }
1986                        if let Some(slot) = &mut end_reason {
1987                            **slot = Some(accepted_reason);
1988                        }
1989                    }
1990                }};
1991            }
1992            // No tool calls — re-issue with stream:true so the user sees tokens.
1993            // `messages` already contains the task; just replay with streaming.
1994            //
1995            // IMPORTANT: the probe round already generated the model's answer in
1996            // `probe_content`. The streaming re-issue is a *second* inference call
1997            // from the same history; if it returns empty (non-determinism, context
1998            // pressure, or model quirk) we fall back to the probe content so the
1999            // user never sees a silent blank response.
2000            let mut body_stream = if let Some(ctx_size) = num_ctx {
2001                serde_json::json!({
2002                    "model": model,
2003                    "messages": &messages,
2004                    "stream": true,
2005                    "tools": tools.clone(),
2006                    "options": { "num_ctx": ctx_size },
2007                })
2008            } else {
2009                serde_json::json!({
2010                    "model": model,
2011                    "messages": &messages,
2012                    "stream": true,
2013                    "tools": tools.clone(),
2014                })
2015            };
2016            // A no-tools model (set on a prior "does not support tools" 400)
2017            // must not see the key on the streaming round either.
2018            if !tools_supported {
2019                if let Some(o) = body_stream.as_object_mut() {
2020                    o.remove("tools");
2021                }
2022            }
2023            // Retry the connection; if we connect successfully but the stream
2024            // drops mid-token, that's a separate (harder) failure mode. Raced
2025            // against the interrupt flag like the probe above.
2026            let sresp = match cancellable(
2027                cancel,
2028                with_backoff_notify(
2029                    &retry,
2030                    || async {
2031                        stream_client
2032                            .post(&chat_url)
2033                            .json(&body_stream)
2034                            .send()
2035                            .await
2036                            .map_err(|e| anyhow::anyhow!("stream request failed: {e}"))
2037                    },
2038                    |attempt, delay| print_retry_indicator(attempt, delay, color),
2039                ),
2040            )
2041            .await
2042            {
2043                Some(r) => r?,
2044                None => return Ok((String::new(), false, accumulated_usage, hallucination_count)),
2045            };
2046
2047            if !sresp.status().is_success() {
2048                if debug {
2049                    print_debug("stream request non-2xx — using probe content", color);
2050                }
2051                maybe_nudge_no_tool_content!(probe_content.as_str(), None);
2052                // Phase 20 §2.2: the probe round produced usable content —
2053                // quality gate met, report it before returning.
2054                if !probe_content.is_empty() {
2055                    emit_accepted(
2056                        &mut on_round_usage,
2057                        round_usage,
2058                        truncation_suspect,
2059                        round_est_raw,
2060                    );
2061                }
2062                if probe_content.is_empty() {
2063                    if let Some(slot) = &mut end_reason {
2064                        **slot = Some(crate::TurnEndReason::Empty);
2065                    }
2066                }
2067                return Ok((probe_content, false, accumulated_usage, hallucination_count));
2068            }
2069            // Cargo-style reasoning spinner: TTY-gated (`color`) and opt-out via
2070            // `[tui] thinking = "off"`. Never in a pipe / `newt worker`.
2071            let show_thinking = color && thinking_stream_enabled();
2072            // #528: models that stream a lone-leading `</think>` (Nemotron et al.)
2073            // need the filter to start inside the reasoning block so the closer
2074            // and the reasoning it follows don't leak into the reply.
2075            let leading_reasoning = crate::reasoning::emits_leading_reasoning(model);
2076            // Step 25.4 (#568): `markdown` is now resolved by the caller
2077            // (`[tui].markdown` ∧ `/markdown` override ∧ color) and read off the
2078            // ctx above — no longer hardcoded to `color`.
2079            let (streamed, stream_usage) = match stream_response(
2080                sresp,
2081                color,
2082                show_thinking,
2083                leading_reasoning,
2084                cancel,
2085                markdown,
2086            )
2087            .await
2088            {
2089                Ok(v) => v,
2090                Err(e) => {
2091                    // #640: the stream connected (2xx) but the BODY broke
2092                    // mid-response — the backend dropped/truncated the stream, or
2093                    // an idle gap exceeded the read timeout. `stream_response`'s
2094                    // only fallible step is the `resp.chunk()` body read, so any
2095                    // error here IS a mid-stream break; left to `?` it surfaces as
2096                    // an opaque "error decoding response body" and ends the whole
2097                    // turn. It is recoverable, not fatal: the `stream:false` probe
2098                    // above already produced the full answer in `probe_content`.
2099                    // Warn and fall back to it — the SAME recovery as the non-2xx
2100                    // path above. (Tiers 2+ of the ladder — retry / shrink /
2101                    // fallback-model / prompt-and-save-preference — are #640.)
2102                    print_harness_notice(
2103                        &format!(
2104                            "stream broke mid-response ({e}) — recovered the answer \
2105                             from the non-streamed probe"
2106                        ),
2107                        color,
2108                    );
2109                    if !probe_content.is_empty() {
2110                        maybe_nudge_no_tool_content!(probe_content.as_str(), None);
2111                        emit_accepted(
2112                            &mut on_round_usage,
2113                            round_usage,
2114                            truncation_suspect,
2115                            round_est_raw,
2116                        );
2117                    }
2118                    if probe_content.is_empty() {
2119                        if let Some(slot) = &mut end_reason {
2120                            **slot = Some(crate::TurnEndReason::Empty);
2121                        }
2122                    }
2123                    return Ok((probe_content, false, accumulated_usage, hallucination_count));
2124                }
2125            };
2126
2127            if streamed.is_empty() {
2128                // The streaming re-issue produced no tokens. Fall back to the
2129                // probe content rather than returning silence.
2130                if debug {
2131                    print_debug(
2132                        &format!(
2133                            "stream returned empty — falling back to probe content ({} chars)",
2134                            probe_content.len()
2135                        ),
2136                        color,
2137                    );
2138                }
2139                if probe_content.is_empty() {
2140                    let merged = merge_round_usage(accumulated_usage, stream_usage);
2141                    let empty_round_usage = merge_round_usage(round_usage, stream_usage);
2142                    let generated_unusable_output = empty_round_usage
2143                        .as_ref()
2144                        .map(|u| u.output_tokens > 0)
2145                        .unwrap_or(false);
2146
2147                    if generated_unusable_output
2148                        && suspicious_empty_retries < SUSPICIOUS_EMPTY_RETRY_CAP
2149                    {
2150                        if trace {
2151                            print_trace(&ollama_response_shape(&json), color);
2152                        }
2153                        if debug {
2154                            let fields = ollama_non_content_fields(&json);
2155                            let field_note = if fields.is_empty() {
2156                                "no known non-content fields".to_string()
2157                            } else {
2158                                format!("non-content fields: {}", fields.join(", "))
2159                            };
2160                            print_debug(
2161                                &format!(
2162                                    "empty assistant content with generated tokens — retrying ({}/{SUSPICIOUS_EMPTY_RETRY_CAP}; {field_note})",
2163                                    suspicious_empty_retries + 1
2164                                ),
2165                                color,
2166                            );
2167                        }
2168                        // Phase 20 §2.2: empty content carrying non-content
2169                        // fields is the thinking-only quirk — report it at
2170                        // detection (at most once per turn) so the prompt-
2171                        // inflating corrective retry isn't re-learned from
2172                        // scratch every session.
2173                        if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2174                            thinking_only_reported = true;
2175                            if let Some(hook) = on_round_usage.as_deref_mut() {
2176                                hook(RoundObservation::ThinkingOnly);
2177                            }
2178                        }
2179                        messages.push(serde_json::json!({
2180                            "role": "user",
2181                            "content": suspicious_empty_retry_nudge(suspicious_empty_retries, &json)
2182                        }));
2183                        accumulated_usage = merged;
2184                        suspicious_empty_retries += 1;
2185                        continue 'round_loop;
2186                    }
2187
2188                    // Both probe and stream are empty — likely context overflow.
2189                    // `input_tokens` is the largest single prompt evaluated this
2190                    // turn (Step 18.1), so the 85%-of-safe-context check now
2191                    // compares one real prompt against the window instead of a
2192                    // multi-round sum that inflated past it after ~2 rounds.
2193                    let overflow_likely = merged
2194                        .as_ref()
2195                        .zip(safe_context)
2196                        .map(|(u, safe)| u.input_tokens >= safe * 85 / 100)
2197                        .unwrap_or(false);
2198                    if overflow_likely && overflow_retries < 2 {
2199                        emit_overflow_notice(
2200                            color,
2201                            merged.as_ref(),
2202                            safe_context,
2203                            model,
2204                            overflow_retries + 1,
2205                        );
2206                        // Compress toward 3/4 of the safe window — comfortably
2207                        // under the 85% trigger (was a blunt count trim before
2208                        // Step 18.4). The retry happens regardless: it is
2209                        // already bounded by `overflow_retries`. The target
2210                        // arithmetic stays in real-token space (`safe_context`
2211                        // and the schema overhead are real-token figures),
2212                        // then converts once into the pipeline's chars/4
2213                        // currency (Phase 20 §2.3).
2214                        let target = calibrate_down(
2215                            safe_context
2216                                .map(|s| (s as usize).saturating_mul(3) / 4)
2217                                .unwrap_or(0)
2218                                .saturating_sub(tool_tokens_real),
2219                            cal,
2220                        );
2221                        let outcome = compress(
2222                            CompressRequest {
2223                                messages: &messages,
2224                                budget: target,
2225                                max_messages: None,
2226                                task,
2227                                hard_budget: true,
2228                                // A suspected silent overflow is a real failure
2229                                // signal — refuse semantics apply (Step 20.3).
2230                                authoritative: true,
2231                                focus: None,
2232                                est: estimation,
2233                                summary_input_cap_floor_chars,
2234                                compaction_store,
2235                            },
2236                            summarizer,
2237                            compress_state,
2238                        )
2239                        .await;
2240                        if let Some(notice) = outcome.notice {
2241                            print_harness_notice(&notice, color);
2242                        }
2243                        if outcome.fired {
2244                            messages = outcome.messages;
2245                            prompt_tracker.invalidate();
2246                            apply_post_compaction_continuation(
2247                                &mut messages,
2248                                &mut narration_nudges,
2249                                outcome.action,
2250                                step_ledger,
2251                                round > 0,
2252                            );
2253                        } else {
2254                            // N1: the retry must differ from the request that
2255                            // just returned empty — when compress was a no-op
2256                            // (Fit / nothing reclaimable), fall back to one
2257                            // structural prune with a tight protected tail.
2258                            let fallback = crate::prune::prune(
2259                                &messages,
2260                                &crate::prune::PruneConfig {
2261                                    keep_last: 2,
2262                                    ..Default::default()
2263                                },
2264                            );
2265                            if fallback.chars_reclaimed > 0 {
2266                                messages = fallback.messages;
2267                                prompt_tracker.invalidate();
2268                            }
2269                        }
2270                        accumulated_usage = merged;
2271                        overflow_retries += 1;
2272                        continue 'round_loop;
2273                    }
2274                    // Phase 20 §2.2: persistent empties past the retry budget
2275                    // at ≥85% of the safe window are silent-overflow evidence
2276                    // — reported at the exit, with the merged prompt figure,
2277                    // before either return below.
2278                    if overflow_likely {
2279                        if let (Some(hook), Some(u)) =
2280                            (on_round_usage.as_deref_mut(), merged.as_ref())
2281                        {
2282                            hook(RoundObservation::SuspectedOverflow {
2283                                prompt_tokens: u.input_tokens,
2284                            });
2285                        }
2286                    }
2287                    if generated_unusable_output {
2288                        if trace {
2289                            print_trace(&ollama_response_shape(&json), color);
2290                        }
2291                        // Phase 20 §2.2: the diagnostic exit is also a
2292                        // thinking-only detection site (at most once per
2293                        // turn; the function returns right after, so the
2294                        // turn-local flag needs no update here).
2295                        if !thinking_only_reported && !ollama_non_content_fields(&json).is_empty() {
2296                            if let Some(hook) = on_round_usage.as_deref_mut() {
2297                                hook(RoundObservation::ThinkingOnly);
2298                            }
2299                        }
2300                        if let Some(slot) = &mut end_reason {
2301                            **slot = Some(crate::TurnEndReason::Empty);
2302                        }
2303                        return Ok((
2304                            suspicious_empty_ollama_diagnostic(&json),
2305                            false,
2306                            merged,
2307                            hallucination_count,
2308                        ));
2309                    }
2310                    let msg = "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)";
2311                    if let Some(slot) = &mut end_reason {
2312                        **slot = Some(crate::TurnEndReason::Empty);
2313                    }
2314                    return Ok((msg.to_string(), false, merged, hallucination_count));
2315                }
2316                // Use probe content; print it since it was never streamed.
2317                maybe_nudge_no_tool_content!(probe_content.as_str(), stream_usage);
2318                // Phase 20 §2.2: non-empty probe content is usable output.
2319                emit_accepted(
2320                    &mut on_round_usage,
2321                    round_usage,
2322                    truncation_suspect,
2323                    round_est_raw,
2324                );
2325                return Ok((
2326                    probe_content,
2327                    false,
2328                    merge_round_usage(accumulated_usage, stream_usage),
2329                    hallucination_count,
2330                ));
2331            }
2332
2333            // Narrate-then-stop rescue: the model produced prose and no tool
2334            // call. If it has already acted this turn (mid-task) or the prose
2335            // reads as intent-to-act, nudge it to actually call the tool and run
2336            // another round instead of ending the turn — what a human "continue"
2337            // does. Bounded by the configured narration_nudge_cap and the round budget so a
2338            // chronic narrator can't loop; after the cap the prose is accepted
2339            // as the final answer (the return below). A genuine from-the-start
2340            // final answer (no prior call, no intent cue) is never nudged.
2341            maybe_nudge_no_tool_content!(streamed.as_str(), stream_usage);
2342            // Phase 20 §2.2: a non-empty streamed answer is usable output.
2343            emit_accepted(
2344                &mut on_round_usage,
2345                round_usage,
2346                truncation_suspect,
2347                round_est_raw,
2348            );
2349            return Ok((
2350                streamed,
2351                true,
2352                merge_round_usage(accumulated_usage, stream_usage),
2353                hallucination_count,
2354            ));
2355        }
2356
2357        // Has tool calls — add assistant turn and execute them.
2358        // Phase 20 §2.2: tool calls are usable output — the dispatched prompt
2359        // is proven accepted regardless of how the turn later ends.
2360        emit_accepted(
2361            &mut on_round_usage,
2362            round_usage,
2363            truncation_suspect,
2364            round_est_raw,
2365        );
2366        messages.push(message.clone());
2367        let mut round_wrote = false;
2368        let mut round_modified_workspace = false;
2369        let mut round_progress = false;
2370        for tc in tool_calls.unwrap() {
2371            let anthropic_native = tc["function"].is_null();
2372            let name = if anthropic_native {
2373                tc["name"].as_str().unwrap_or("unknown")
2374            } else {
2375                tc["function"]["name"].as_str().unwrap_or("unknown")
2376            };
2377            let args = if anthropic_native {
2378                tc["input"].clone()
2379            } else {
2380                match &tc["function"]["arguments"] {
2381                    serde_json::Value::String(s) => {
2382                        serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
2383                    }
2384                    v => v.clone(),
2385                }
2386            };
2387            if is_hallucination(name, &args) {
2388                hallucination_count += 1;
2389            }
2390            // Step 27.3/#771: short-circuit selected exact repeats — steer
2391            // instead of re-executing a dead or already-useful call. The bogus
2392            // emission is still counted above; we just don't run it again.
2393            if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
2394                if let Some(rec) = tool_events.as_deref_mut() {
2395                    rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
2396                }
2397                messages.push(serde_json::json!({ "role": "tool", "content": steer }));
2398                continue;
2399            }
2400            if !is_read_only_call(name, &args) {
2401                round_wrote = true;
2402            }
2403            // Organic save_note use resets the memory-nudge counter (the
2404            // read-only-rounds reset pattern) — active curators never see it.
2405            if name == "save_note" && note_sink.is_some() {
2406                if let Some(n) = note_nudge.as_deref_mut() {
2407                    n.note_saved();
2408                }
2409            }
2410            // retry technique: snapshot the file's pre-write bytes before the
2411            // write tool runs, so the post-turn gate can revert exactly newt's writes.
2412            ledger_note_write(write_ledger, name, &args, workspace);
2413            let tool_t0 = std::time::Instant::now();
2414            // #727: intercept the read-only budget self-read here. Its answer is
2415            // dynamic per-turn loop state — the num_ctx input ceiling and the
2416            // conversation's token estimate — which are in scope in the loop, not
2417            // inside execute_tool. `prompt_tracker.current` is real-token currency,
2418            // the same the ceiling is in. The rendered string then flows through
2419            // all the normal bookkeeping below (ok, tool_events, phantom_reaches,
2420            // spill), so aliases recorded as Rewrites stay correct.
2421            let result = if tools::is_context_remaining_call(name) {
2422                let report = budget::render_context_budget(
2423                    prompt_tracker.current(&messages, Some(&tools), cal, estimation),
2424                    num_ctx_input_ceiling(num_ctx, input_ceiling_pct),
2425                    num_ctx,
2426                    input_ceiling_pct as usize,
2427                    low_budget_pct,
2428                );
2429                display::print_tool_call("get_context_remaining", "", color);
2430                display::print_tool_output(&report, tool_output_lines, color);
2431                report
2432            } else {
2433                // #297 follow-up: race the tool dispatch against the turn's
2434                // cancel flag. A mid-tool interrupt (Esc / Ctrl-C) now drops the
2435                // in-flight future here instead of waiting for the tool to
2436                // return — and for exec that dropped future triggers
2437                // `kill_on_drop` on the child *tree*, so a hung `run_command`
2438                // dies the instant the user asks for it rather than at the
2439                // host-exec timeout ceiling. `is_cancelled` between rounds still
2440                // catches the abandoned turn; this closes the *during-a-tool*
2441                // window that a foreground child on the tty used to wedge.
2442                let dispatch = execute_tool_with_offload(
2443                    name,
2444                    &args,
2445                    workspace,
2446                    color,
2447                    tool_output_lines,
2448                    caveats,
2449                    mcp,
2450                    build_check_cmd.as_deref(),
2451                    // Reborrow + re-coerce: shortens the trait-object lifetime to
2452                    // this call (Option<&mut dyn _> is invariant, so the longer
2453                    // ChatCtx lifetime can't unify directly).
2454                    note_sink
2455                        .as_deref_mut()
2456                        .map(|s| &mut *s as &mut dyn NoteSink),
2457                    recall_source,
2458                    memory_source,
2459                    // #263 prompted grants — same reborrow pattern as note_sink.
2460                    permission_gate
2461                        .as_deref_mut()
2462                        .map(|g| &mut *g as &mut dyn PermissionGate),
2463                    // #307: the active preset's exec floor (the bypass ceiling).
2464                    exec_floor,
2465                    // PR4: the injected embedded-git capability (None for headless).
2466                    git_tool,
2467                    // #479: the injected crew/team orchestration (None for headless).
2468                    crew_runner,
2469                    scratchpad_store,
2470                    code_search,
2471                    experience_store,
2472                    step_ledger,
2473                    tool_offload,
2474                    spill_store,
2475                    persona_tools,
2476                );
2477                // If the turn was cancelled mid-tool, `cancellable` returns
2478                // None and the dispatch future above is already dropped (its
2479                // child reaped via kill_on_drop). Synthesize a cancellation
2480                // result so the loop unwinds cleanly on the next `is_cancelled`
2481                // check instead of pretending the tool produced output.
2482                match cancellable(cancel, dispatch).await {
2483                    Some(r) => r,
2484                    None => format!("error: {name} interrupted — tool cancelled before completion"),
2485                }
2486            };
2487            // 17.6: record the call for the turn's events column — args are
2488            // digested (never stored raw), duration is a display claim.
2489            // Step 27.3/#771: classify once; remember outcomes that should make
2490            // an exact repeat self-correct next round.
2491            let ok = tools::tool_result_ok(&result);
2492            if ok && is_workspace_write_call(name) {
2493                round_modified_workspace = true;
2494            }
2495            if ok && meaningful_workflow_progress(name, &result) {
2496                round_progress = true;
2497            }
2498            repeat_calls.record(name, &args, ok, &result);
2499            if workflow_runtime.record_tool_result(&result) {
2500                round_progress = true;
2501            }
2502            if let Some(rec) = tool_events.as_deref_mut() {
2503                rec.push(crate::ToolEvent::from_call(
2504                    name,
2505                    &args,
2506                    ok,
2507                    u64::try_from(tool_t0.elapsed().as_millis()).ok(),
2508                ));
2509            }
2510            // #717: record any phantom/capability reach (alias / hallucination
2511            // / real-tool empty miss) for the alias-seam telemetry. #479 (G4)
2512            // composes the gated-off seam here, where `advertise_team` is known:
2513            // a `crew`/`compose_roster` reach with the surface OFF is a real name
2514            // (so `classify_phantom_reach` never flags it) but exactly the
2515            // delegation signal we want to mine for the common OFF default.
2516            if let Some(pr) = phantom_reaches.as_deref_mut() {
2517                if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
2518                    .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
2519                {
2520                    pr.push(crate::PhantomReach {
2521                        name_as_called: name.to_string(),
2522                        resolution,
2523                        active_context_features: Vec::new(),
2524                    });
2525                }
2526            }
2527            // #867 Part A: ledger the verified paths this result surfaced
2528            // BEFORE the offload may spill the text out of the transcript.
2529            observed_paths.record(&result, &observed_resolver);
2530            messages.push(serde_json::json!({
2531                "role": "tool",
2532                // Step 26.3 (#584): offload an oversized result (redact → spill →
2533                // teaser+handle) when tool_offload is on; unchanged otherwise.
2534                "content": maybe_offload_tool_result(name, result, tool_offload, spill_store)
2535            }));
2536        }
2537        if round_wrote {
2538            read_only_rounds = 0;
2539        } else {
2540            read_only_rounds = read_only_rounds.saturating_add(1);
2541        }
2542        workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
2543    }
2544
2545    // Reached the round cap. Trim the bloated message list so the final
2546    // summary request doesn't overflow the model's context window, then
2547    // make ONE tools-disabled completion so the user gets a real partial answer.
2548    let trimmed = trim_for_summary(&messages, 2, 6);
2549    // Step 27.5: salvage the plan/state ledger + the failed-call count so the
2550    // summary reflects progress and the fallback advice is honest.
2551    let progress = cap_exit_progress(step_ledger, scratchpad_store);
2552    let (text, streamed, usage) = final_summary_ollama(
2553        &client,
2554        &chat_url,
2555        model,
2556        trimmed,
2557        CapExit {
2558            max_tool_rounds,
2559            accumulated: accumulated_usage,
2560            wasted_calls: repeat_calls.total_failures(),
2561            progress,
2562            observed: observed_paths.into_vec(),
2563        },
2564    )
2565    .await?;
2566    // #867: the evidence for this summary was just trimmed away, which is
2567    // exactly when a model fabricates plausible file paths — verify every
2568    // cited path against the workspace and append a visible refutation for
2569    // any that don't exist. Appends only; the model's prose is never edited.
2570    let text = claim_check::annotate_against_workspace(text, workspace);
2571    if let Some(slot) = &mut end_reason {
2572        **slot = Some(crate::TurnEndReason::RoundCap);
2573    }
2574    Ok((text, streamed, usage, hallucination_count))
2575}
2576
2577/// Returns `true` when `name` is a tool that doesn't modify the workspace.
2578/// Used to count consecutive read-only rounds and inject a write-nudge.
2579/// `save_note` writes *memory*, not the workspace — a round that only saved
2580/// a note must not suppress the stop-exploring-start-writing nudge; `recall`
2581/// (17.5) reads past conversations and is likewise pure exploration.
2582/// First line of a tool result, capped — used as the remembered error reason in
2583/// [`RepeatCallGuard`] so the steering message is short.
2584fn first_line(s: &str) -> String {
2585    s.lines().next().unwrap_or("").chars().take(200).collect()
2586}
2587
2588/// Per-run guard against a weak model looping on a tool call whose result should
2589/// already be actionable. Step 27.3 covered failures: the forensic session showed
2590/// the model re-issuing the *identical* failed `run_command` three times and
2591/// re-reading the same file ~8×, burning rounds. Later field reports added
2592/// success-shaped loops: no-result probes (`recall`, `state_get`), successful
2593/// `web_fetch` calls with real content, and successful read-only shell probes.
2594///
2595/// Keyed by `(name, canonical args)`, it short-circuits selected exact repeats
2596/// with steering instead of re-executing them. The classifier sees every call
2597/// outcome, but most successes deliberately stay repeatable; only failures,
2598/// success-shaped no-results, successful `web_fetch`, and successful read-only
2599/// shell probes are memoized. It also counts failures per tool name so the steer
2600/// can escalate ("stop using `run_command` — it keeps failing this session; use
2601/// the embedded tools"). This handles ANY persistently-failing tool — a dead
2602/// shell, a denied command, an unimplemented op — without needing to know *why*
2603/// it fails (shell availability is a build/config property with no clean runtime
2604/// signal; see `tools::ocap_disabled` docs).
2605#[derive(Debug, Clone, PartialEq, Eq)]
2606enum RepeatMemo {
2607    Failure {
2608        first_line: String,
2609    },
2610    NoResult {
2611        reason: String,
2612    },
2613    EvidenceObserved {
2614        subject: String,
2615        advice: &'static str,
2616    },
2617}
2618
2619#[derive(Default)]
2620struct RepeatCallGuard {
2621    /// `(name + canonical args)` → the prior outcome that should steer an exact repeat.
2622    repeat_memos: std::collections::HashMap<String, RepeatMemo>,
2623    /// `name` → how many times it has failed this run (any args).
2624    fails_by_tool: std::collections::HashMap<String, usize>,
2625}
2626
2627impl RepeatCallGuard {
2628    /// How many consecutive failures of one tool before the steer escalates to
2629    /// "stop using it".
2630    const ESCALATE_AFTER: usize = 2;
2631
2632    fn key(name: &str, args: &serde_json::Value) -> String {
2633        // The model emits byte-identical args when it loops (confirmed by the
2634        // identical forensic args digests), so the compact JSON is a stable key.
2635        format!("{name}\u{1}{args}")
2636    }
2637
2638    /// Steering message if this exact `(name, args)` already produced a memoized
2639    /// outcome that should not be repeated this run, else `None` (let it execute).
2640    fn repeat_steer(&self, name: &str, args: &serde_json::Value) -> Option<String> {
2641        let key = Self::key(name, args);
2642        match self.repeat_memos.get(&key)? {
2643            RepeatMemo::Failure { first_line: prev } => {
2644                let mut msg = format!(
2645                    "You already called `{name}` with these exact arguments and it failed: {prev}. \
2646                     Do NOT repeat the same call — use a different tool or different arguments."
2647                );
2648                if self.fails_by_tool.get(name).copied().unwrap_or(0) >= Self::ESCALATE_AFTER {
2649                    msg.push_str(&format!(
2650                        " `{name}` has failed repeatedly this session; stop using it and prefer \
2651                         the embedded tools (read_file, edit_file, write_file, find, git)."
2652                    ));
2653                }
2654                Some(msg)
2655            }
2656            RepeatMemo::NoResult { reason } => Some(format!(
2657                "You already ran `{name}` with these exact arguments this turn and {reason}. \
2658                 Don't repeat the identical call — create or update the missing state, change \
2659                 the arguments when the tool accepts them, or use a different tool."
2660            )),
2661            RepeatMemo::EvidenceObserved { subject, advice } => Some(format!(
2662                "You already observed {subject} with `{name}` and received output. Do NOT repeat \
2663                 the identical call — {advice}"
2664            )),
2665        }
2666    }
2667
2668    fn successful_fetch_url(name: &str, args: &serde_json::Value, result: &str) -> Option<String> {
2669        if name != "web_fetch" {
2670            return None;
2671        }
2672        let url = args.get("url")?.as_str()?.trim();
2673        if url.is_empty() || result.trim().is_empty() {
2674            None
2675        } else {
2676            Some(url.chars().take(200).collect())
2677        }
2678    }
2679
2680    fn successful_read_only_shell_command(
2681        name: &str,
2682        args: &serde_json::Value,
2683        result: &str,
2684    ) -> Option<String> {
2685        if name != "run_command" || result.trim().is_empty() {
2686            return None;
2687        }
2688        let command = args.get("command")?.as_str()?.trim();
2689        if is_read_only_shell_probe(command) {
2690            Some(command.chars().take(200).collect())
2691        } else {
2692            None
2693        }
2694    }
2695
2696    /// #718: classify a SUCCESS-shaped result that is empty *by design* — it
2697    /// passes `tool_result_ok` (ok=true) so the failure path never sees it, yet
2698    /// the model loops the identical call. Pure; keyed on the exact result
2699    /// prefixes (`recall.rs` keeps "no matches in past conversations";
2700    /// `scratchpad.rs` returns "no such key: ..."). `None` when the result
2701    /// carries real content (nothing to steer).
2702    fn no_result_reason(name: &str, result: &str) -> Option<&'static str> {
2703        match name {
2704            "recall" if result.starts_with("no matches in past conversations") => Some(
2705                "it returned no matches (and recall cannot see the current conversation \
2706                 — use resume_context for THIS conversation)",
2707            ),
2708            "state_get" if result.starts_with("no such key") => {
2709                Some("the key is not set (state_get returned \"no such key\")")
2710            }
2711            "plan_get" if result.starts_with("no active plan") => Some(
2712                "it found no active plan; call update_plan now with a short ordered plan if the \
2713                 work has more than one step",
2714            ),
2715            _ => None,
2716        }
2717    }
2718
2719    /// Classify a just-executed call into the subset of outcomes that should
2720    /// steer an exact repeat. This function sees all calls, but deliberately
2721    /// returns `None` for ordinary successes so valid repeated work (builds,
2722    /// tests, rereads after edits, write-capable commands) can keep running.
2723    fn classify_repeat_memo(
2724        name: &str,
2725        args: &serde_json::Value,
2726        ok: bool,
2727        result: &str,
2728    ) -> Option<RepeatMemo> {
2729        if !ok {
2730            return Some(RepeatMemo::Failure {
2731                first_line: first_line(result),
2732            });
2733        }
2734        if let Some(reason) = Self::no_result_reason(name, result) {
2735            return Some(RepeatMemo::NoResult {
2736                reason: reason.to_string(),
2737            });
2738        }
2739        if let Some(url) = Self::successful_fetch_url(name, args, result) {
2740            return Some(RepeatMemo::EvidenceObserved {
2741                subject: format!("`{url}`"),
2742                advice: "use the fetched content above, fetch a different URL, inspect local \
2743                         files, or answer the user.",
2744            });
2745        }
2746        if let Some(command) = Self::successful_read_only_shell_command(name, args, result) {
2747            return Some(RepeatMemo::EvidenceObserved {
2748                subject: format!("read-only shell probe `{command}`"),
2749                advice: "use the observed output above, change the query, inspect a different \
2750                         file, or make the next edit/test decision.",
2751            });
2752        }
2753        None
2754    }
2755
2756    /// Record a just-executed call's outcome. Failures are also counted so the
2757    /// steer can escalate; success-shaped memos are not counted because they are
2758    /// not hard failures.
2759    fn record(&mut self, name: &str, args: &serde_json::Value, ok: bool, result: &str) {
2760        if !ok {
2761            *self.fails_by_tool.entry(name.to_string()).or_default() += 1;
2762        }
2763        if let Some(memo) = Self::classify_repeat_memo(name, args, ok, result) {
2764            self.repeat_memos.insert(Self::key(name, args), memo);
2765        }
2766    }
2767
2768    /// Total failed tool executions this run (across all tools) — a signal that
2769    /// a cap exit was thrash, not lack of rounds (Step 27.5).
2770    fn total_failures(&self) -> usize {
2771        self.fails_by_tool.values().sum()
2772    }
2773}
2774
2775#[derive(Debug, Clone, PartialEq, Eq)]
2776struct WorkflowErrorEvidence {
2777    fingerprint: String,
2778    observations: usize,
2779}
2780
2781#[derive(Debug, Default)]
2782struct WorkflowRuntimeState {
2783    error_evidence: Option<WorkflowErrorEvidence>,
2784    read_only_rounds_after_evidence: usize,
2785    writes_after_evidence: usize,
2786    rounds_since_progress: Option<usize>,
2787    /// Override for [`WORKFLOW_RECENT_PROGRESS_ROUNDS`], set once per turn from
2788    /// the matching [`crate::WorkflowSteerer`] workflow's
2789    /// `progress_horizon_rounds` (diagnostic workflows legitimately need more
2790    /// read-only rounds between plan/edit checkpoints than routine edits do —
2791    /// see `diagnose_failure.toml`). `None` uses the shared default.
2792    progress_horizon_rounds: Option<usize>,
2793    step_lock_nudges: usize,
2794    rediscovery_nudges: usize,
2795}
2796
2797impl WorkflowRuntimeState {
2798    const STEP_LOCK_NUDGE_CAP: usize = 3;
2799    const REDISCOVERY_NUDGE_CAP: usize = 2;
2800
2801    /// Set once per turn from the matching workflow's horizon override, if
2802    /// any. A no-op call (`None`) leaves the shared default in effect.
2803    fn set_progress_horizon(&mut self, rounds: Option<usize>) {
2804        self.progress_horizon_rounds = rounds;
2805    }
2806
2807    fn progress_horizon(&self) -> usize {
2808        self.progress_horizon_rounds
2809            .unwrap_or(WORKFLOW_RECENT_PROGRESS_ROUNDS)
2810    }
2811
2812    fn record_tool_result(&mut self, result: &str) -> bool {
2813        let Some(fingerprint) = workflow_error_fingerprint(result) else {
2814            return false;
2815        };
2816        match self.error_evidence.as_mut() {
2817            Some(evidence) if evidence.fingerprint == fingerprint => {
2818                evidence.observations = evidence.observations.saturating_add(1);
2819                false
2820            }
2821            _ => {
2822                self.error_evidence = Some(WorkflowErrorEvidence {
2823                    fingerprint,
2824                    observations: 1,
2825                });
2826                self.read_only_rounds_after_evidence = 0;
2827                self.writes_after_evidence = 0;
2828                self.step_lock_nudges = 0;
2829                self.rediscovery_nudges = 0;
2830                true
2831            }
2832        }
2833    }
2834
2835    fn record_round_outcome(&mut self, round_wrote: bool, round_progress: bool) {
2836        if round_progress {
2837            self.rounds_since_progress = Some(0);
2838        } else if let Some(rounds) = self.rounds_since_progress.as_mut() {
2839            *rounds = rounds.saturating_add(1);
2840        }
2841        if self.error_evidence.is_none() {
2842            return;
2843        }
2844        if round_wrote {
2845            self.writes_after_evidence = self.writes_after_evidence.saturating_add(1);
2846            self.read_only_rounds_after_evidence = 0;
2847        } else {
2848            self.read_only_rounds_after_evidence =
2849                self.read_only_rounds_after_evidence.saturating_add(1);
2850        }
2851    }
2852
2853    fn round_start_nudge(
2854        &mut self,
2855        step_ledger: Option<&dyn scheduled::StepLedger>,
2856    ) -> Option<String> {
2857        let evidence = self.error_evidence.as_ref()?;
2858        if self.writes_after_evidence > 0 || self.read_only_rounds_after_evidence == 0 {
2859            return None;
2860        }
2861        if self.step_lock_nudges >= Self::STEP_LOCK_NUDGE_CAP {
2862            return None;
2863        }
2864        self.step_lock_nudges += 1;
2865        Some(workflow_step_lock_nudge(
2866            &evidence.fingerprint,
2867            evidence.observations,
2868            active_step_description(step_ledger).as_deref(),
2869        ))
2870    }
2871
2872    fn rediscovery_nudge(
2873        &mut self,
2874        classification: Option<&crate::NudgeClassification>,
2875        content: &str,
2876        step_ledger: Option<&dyn scheduled::StepLedger>,
2877    ) -> Option<String> {
2878        let evidence = self.error_evidence.as_ref()?;
2879        if self.writes_after_evidence > 0 {
2880            return None;
2881        }
2882        if self.rediscovery_nudges >= Self::REDISCOVERY_NUDGE_CAP {
2883            return None;
2884        }
2885        let classified_stall = classification.is_some_and(|c| {
2886            matches!(
2887                c.class,
2888                crate::NudgeClass::PendingAction | crate::NudgeClass::PlanUpdate
2889            )
2890        });
2891        if !classified_stall && !looks_like_error_rediscovery(content) {
2892            return None;
2893        }
2894        self.rediscovery_nudges += 1;
2895        Some(workflow_rediscovery_nudge(
2896            &evidence.fingerprint,
2897            active_step_description(step_ledger).as_deref(),
2898        ))
2899    }
2900
2901    fn cap_grace_nudge(
2902        &mut self,
2903        step_ledger: Option<&dyn scheduled::StepLedger>,
2904        max_tool_rounds: usize,
2905        workflow_grace_rounds: usize,
2906    ) -> Option<String> {
2907        if workflow_grace_rounds == 0 {
2908            return None;
2909        }
2910        let active_step = active_step_description(step_ledger);
2911        let recent_progress = self
2912            .rounds_since_progress
2913            .is_some_and(|rounds| rounds <= self.progress_horizon());
2914        if let Some(evidence) = self.error_evidence.as_ref() {
2915            if self.writes_after_evidence > 0 {
2916                return Some(workflow_post_write_grace_nudge(
2917                    &evidence.fingerprint,
2918                    active_step.as_deref(),
2919                    max_tool_rounds,
2920                    workflow_grace_rounds,
2921                ));
2922            }
2923            if self.read_only_rounds_after_evidence > 0 || recent_progress {
2924                return Some(workflow_cap_grace_nudge(
2925                    &evidence.fingerprint,
2926                    active_step.as_deref(),
2927                    max_tool_rounds,
2928                    workflow_grace_rounds,
2929                ));
2930            }
2931        }
2932        if active_step.is_some() && recent_progress {
2933            return Some(workflow_progress_grace_nudge(
2934                active_step.as_deref(),
2935                max_tool_rounds,
2936                workflow_grace_rounds,
2937            ));
2938        }
2939        None
2940    }
2941}
2942
2943fn workflow_error_fingerprint(result: &str) -> Option<String> {
2944    build_error_fingerprint(result).or_else(|| edit_miss_fingerprint(result))
2945}
2946
2947fn build_error_fingerprint(result: &str) -> Option<String> {
2948    let mut pending_error: Option<String> = None;
2949    let mut fingerprints = Vec::new();
2950    for line in result.lines() {
2951        let trimmed = line.trim();
2952        if trimmed.starts_with("error[") || trimmed.starts_with("error:") {
2953            pending_error = Some(normalize_error_line(trimmed));
2954            continue;
2955        }
2956        if let Some(rest) = trimmed.strip_prefix("-->") {
2957            if let Some(error) = pending_error.take() {
2958                let location = rest
2959                    .split_whitespace()
2960                    .next()
2961                    .unwrap_or("")
2962                    .trim()
2963                    .trim_start_matches("./");
2964                if location.is_empty() {
2965                    fingerprints.push(error);
2966                } else {
2967                    fingerprints.push(format!("{location} {error}"));
2968                }
2969                if fingerprints.len() >= 3 {
2970                    break;
2971                }
2972            }
2973        }
2974    }
2975    if fingerprints.is_empty() {
2976        pending_error.map(|e| e.chars().take(240).collect())
2977    } else {
2978        Some(fingerprints.join(" | ").chars().take(500).collect())
2979    }
2980}
2981
2982fn edit_miss_fingerprint(result: &str) -> Option<String> {
2983    let lc = result.to_ascii_lowercase();
2984    if !(lc.contains("old_string")
2985        && (lc.contains("not found")
2986            || lc.contains("old string not found")
2987            || lc.contains("matches 0")
2988            || lc.contains("no match")))
2989    {
2990        return None;
2991    }
2992    let line = result
2993        .lines()
2994        .find(|line| {
2995            let l = line.to_ascii_lowercase();
2996            l.contains("old_string")
2997                && (l.contains("not found") || l.contains("matches 0") || l.contains("no match"))
2998        })
2999        .unwrap_or("edit_file old_string not found");
3000    Some(format!("edit_file {}", normalize_error_line(line)))
3001}
3002
3003fn normalize_error_line(line: &str) -> String {
3004    let mut out = String::new();
3005    let mut in_ws = false;
3006    for c in line.chars() {
3007        if c.is_whitespace() {
3008            if !in_ws {
3009                out.push(' ');
3010                in_ws = true;
3011            }
3012        } else if c != '`' {
3013            out.push(c);
3014            in_ws = false;
3015        }
3016    }
3017    out.chars().take(240).collect()
3018}
3019
3020fn active_step_description(step_ledger: Option<&dyn scheduled::StepLedger>) -> Option<String> {
3021    let snapshot = step_ledger?.snapshot();
3022    snapshot
3023        .steps
3024        .iter()
3025        .find(|step| step.status == StepStatus::Active)
3026        .or_else(|| {
3027            snapshot
3028                .steps
3029                .iter()
3030                .find(|step| step.status != StepStatus::Done)
3031        })
3032        .map(|step| step.description.clone())
3033}
3034
3035fn workflow_step_lock_nudge(
3036    fingerprint: &str,
3037    observations: usize,
3038    active_step: Option<&str>,
3039) -> String {
3040    let active = active_step
3041        .map(|step| format!(" Active step: '{step}'."))
3042        .unwrap_or_default();
3043    format!(
3044        "<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."
3045    )
3046}
3047
3048fn workflow_rediscovery_nudge(fingerprint: &str, active_step: Option<&str>) -> String {
3049    let active = active_step
3050        .map(|step| format!(" Active step: '{step}'."))
3051        .unwrap_or_default();
3052    format!(
3053        "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."
3054    )
3055}
3056
3057fn workflow_cap_grace_nudge(
3058    fingerprint: &str,
3059    active_step: Option<&str>,
3060    max_tool_rounds: usize,
3061    workflow_grace_rounds: usize,
3062) -> String {
3063    let active = active_step
3064        .map(|step| format!(" Active step: '{step}'."))
3065        .unwrap_or_default();
3066    format!(
3067        "<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."
3068    )
3069}
3070
3071fn workflow_post_write_grace_nudge(
3072    fingerprint: &str,
3073    active_step: Option<&str>,
3074    max_tool_rounds: usize,
3075    workflow_grace_rounds: usize,
3076) -> String {
3077    let active = active_step
3078        .map(|step| format!(" Active step: '{step}'."))
3079        .unwrap_or_default();
3080    format!(
3081        "<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."
3082    )
3083}
3084
3085fn workflow_progress_grace_nudge(
3086    active_step: Option<&str>,
3087    max_tool_rounds: usize,
3088    workflow_grace_rounds: usize,
3089) -> String {
3090    let active = active_step
3091        .map(|step| format!(" Active step: '{step}'."))
3092        .unwrap_or_default();
3093    format!(
3094        "<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."
3095    )
3096}
3097
3098fn looks_like_error_rediscovery(content: &str) -> bool {
3099    let lc = content.to_ascii_lowercase();
3100    (lc.contains("summary of findings")
3101        || lc.contains("root cause")
3102        || lc.contains("current state")
3103        || lc.contains("remaining work")
3104        || lc.contains("build failure"))
3105        && (lc.contains("error") || lc.contains("build") || lc.contains("compile"))
3106}
3107
3108/// Render the agent's working-memory progress (`<plan>` checklist + `<state>`)
3109/// at a cap exit, so partial work is salvaged into the final summary / fallback
3110/// instead of being lost (Step 27.5). `None` when both are empty.
3111fn cap_exit_progress(
3112    step_ledger: Option<&dyn scheduled::StepLedger>,
3113    scratchpad_store: Option<&dyn scratchpad::ScratchpadStore>,
3114) -> Option<String> {
3115    let plan = step_ledger.and_then(scheduled::plan_block);
3116    let state = scratchpad_store.and_then(scratchpad::scratchpad_state_block);
3117    let parts: Vec<String> = [plan, state].into_iter().flatten().collect();
3118    (!parts.is_empty()).then(|| parts.join("\n\n"))
3119}
3120
3121fn is_read_only_tool(name: &str) -> bool {
3122    matches!(
3123        name,
3124        "list_dir"
3125            | "read_file"
3126            | "find"
3127            | "search"
3128            | "web_fetch"
3129            | "use_skill"
3130            | "save_note"
3131            | "recall"
3132    )
3133}
3134
3135fn is_read_only_call(name: &str, args: &serde_json::Value) -> bool {
3136    is_read_only_tool(name)
3137        || (name == "run_command"
3138            && args
3139                .get("command")
3140                .and_then(|v| v.as_str())
3141                .is_some_and(is_read_only_shell_probe))
3142}
3143
3144fn is_workspace_write_call(name: &str) -> bool {
3145    matches!(name, "write_file" | "edit_file")
3146}
3147
3148fn maybe_offload_tool_result(
3149    name: &str,
3150    result: String,
3151    tool_offload: bool,
3152    spill_store: Option<&dyn spill::SpillStore>,
3153) -> String {
3154    if matches!(name, "run_command" | "lifecycle") {
3155        result
3156    } else {
3157        spill::maybe_offload(result, tool_offload, spill_store)
3158    }
3159}
3160
3161fn meaningful_workflow_progress(name: &str, result: &str) -> bool {
3162    match name {
3163        "update_plan" => true,
3164        "write_file" => result.starts_with("wrote ") || result.starts_with("✓ wrote "),
3165        "edit_file" => edit_result_changed_file(result),
3166        _ => false,
3167    }
3168}
3169
3170fn edit_result_changed_file(result: &str) -> bool {
3171    result.starts_with("edited ") || result.starts_with("✓ edited ")
3172}
3173
3174fn is_read_only_shell_probe(command: &str) -> bool {
3175    let command = command.trim();
3176    if command.is_empty() {
3177        return false;
3178    }
3179    const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
3180    if command.contains(SHELL_META) {
3181        return false;
3182    }
3183    let mut tokens = command.split_ascii_whitespace();
3184    let Some(program) = tokens.next() else {
3185        return false;
3186    };
3187    match program {
3188        "grep" | "rg" | "head" | "tail" | "wc" | "pwd" => true,
3189        "sed" => !tokens.any(|t| t == "-i" || t.starts_with("-i")),
3190        _ => false,
3191    }
3192}
3193
3194// The narrate-then-stop rescue budget is `ChatCtx.narration_nudge_cap`
3195// (`[tui] narration_nudge_cap`, default 1, per-model `[[model_tuning]]`
3196// override) — promoted from a hardcoded const here (lever L3). After the cap
3197// its narration is accepted as the final answer.
3198/// Max "you ended while a plan still has open steps" nudges per turn. This is
3199/// state-driven from the plan ledger, not prose-matched.
3200const PENDING_PLAN_NUDGE_CAP: usize = 1;
3201/// Max "generated hidden thinking but no visible content/tool call" retries per
3202/// turn. The first retry is generic; the second is explicit that hidden
3203/// thinking is not an action. Bounded so a broken backend still exits with the
3204/// diagnostic.
3205const SUSPICIOUS_EMPTY_RETRY_CAP: u32 = 2;
3206/// Max "you claimed file context changed under you without verifying" nudges
3207/// per turn. Kept separate from narration nudges: this is a blocker-specific
3208/// ground-truth check, not generic intent-to-act recovery.
3209const STALE_FILE_NUDGE_CAP: usize = 1;
3210/// Recent-progress horizon used to decide whether a configured workflow grace
3211/// window should activate at the normal round cap.
3212const WORKFLOW_RECENT_PROGRESS_ROUNDS: usize = 3;
3213
3214fn tail_on_char_boundary(s: &str, max_bytes: usize) -> &str {
3215    let cut = s.len().saturating_sub(max_bytes);
3216    let start = (cut..=s.len())
3217        .find(|&i| s.is_char_boundary(i))
3218        .unwrap_or(0);
3219    &s[start..]
3220}
3221
3222/// Compatibility wrapper for the narrate-then-stop classifier. Production turns
3223/// use [`crate::NudgeClassifier::load_default`] so `~/.newt/classifiers/nudge.toml`
3224/// can tune the examples; pure tests use the built-in prototypes here.
3225#[cfg(test)]
3226fn looks_like_intent_to_act(content: &str) -> bool {
3227    crate::NudgeClassifier::builtin().is_pending_action(content)
3228}
3229
3230/// Heuristic: did the model stop because it *believes* a file changed under it,
3231/// without first proving that via git/filesystem ground truth? This catches the
3232/// "stale line numbers ⇒ operator should restore the file" stall: a context
3233/// summary or partial read gets mistaken for a concurrent edit, and the model
3234/// stops instead of running read-only checks.
3235fn looks_like_unverified_stale_file_blocker(content: &str) -> bool {
3236    const FILE_CUES: &[&str] = &[
3237        "file",
3238        "line reference",
3239        "line references",
3240        "old_string",
3241        "edit_file",
3242        ".rs",
3243        ".toml",
3244        ".md",
3245    ];
3246    const STALE_CUES: &[&str] = &[
3247        "modified out from under",
3248        "changed out from under",
3249        "edited out from under",
3250        "modified concurrently",
3251        "changed concurrently",
3252        "stale context",
3253        "contexts are stale",
3254        "context is stale",
3255        "old line references",
3256        "line references are invalid",
3257        "file grew from",
3258        "grew from",
3259    ];
3260    const BLOCKER_CUES: &[&str] = &[
3261        "blocked",
3262        "cannot safely",
3263        "can't safely",
3264        "could land in the wrong place",
3265        "corrupt the code",
3266        "restore",
3267        "git checkout",
3268        "revert",
3269        "operator should",
3270        "human should",
3271        "recommendation",
3272    ];
3273
3274    let lc = content.to_lowercase();
3275    let tail = tail_on_char_boundary(&lc, 1_200);
3276    FILE_CUES.iter().any(|c| tail.contains(c))
3277        && STALE_CUES.iter().any(|c| tail.contains(c))
3278        && BLOCKER_CUES.iter().any(|c| tail.contains(c))
3279}
3280
3281/// The corrective injected when the model narrated its next action but emitted
3282/// no tool call (the narrate-then-stop stall). Sibling of [`read_only_action_nudge`].
3283fn narration_action_nudge() -> String {
3284    "You described what you were about to do but did not call any tool, so \
3285     nothing actually happened. If you intended to act, emit the tool call now \
3286     (for example edit_file or write_file with the real arguments) — do not just \
3287     describe it. If you are genuinely finished, say so explicitly in one \
3288     sentence."
3289        .to_string()
3290}
3291
3292/// The act-now directive appended after a mid-turn compaction replaced the
3293/// middle with a summary. The summary wrapper deliberately de-actions itself
3294/// ("REFERENCE ONLY" — weak models otherwise treat it as fresh instructions);
3295/// the inverse hazard is losing momentum entirely: post-compaction is exactly
3296/// where a weak model narrates instead of acting, and the corrective text of
3297/// an already-spent narration nudge may have just been summarized away. This
3298/// re-arms intent: mid-task, active step named, next output a tool call.
3299/// Carries [`compress::CONTINUATION_PREFIX`] so later compressions neither
3300/// anchor the tail on it nor keep more than one alive.
3301fn post_compaction_continuation(step_ledger: Option<&dyn scheduled::StepLedger>) -> String {
3302    let step_clause = active_step_description(step_ledger)
3303        .map(|step| format!(" Active step: '{step}'."))
3304        .unwrap_or_default();
3305    format!(
3306        "{} You are mid-task: the context above was just compacted, not \
3307         completed.{step_clause} Continue working — your next output should be \
3308         the next concrete tool call (re-read any file you are about to edit \
3309         first, since full file contents were not preserved). Do not summarize \
3310         what happened and do not re-plan.",
3311        compress::CONTINUATION_PREFIX
3312    )
3313}
3314
3315/// After a compaction pass replaced the middle with a summary (or the static
3316/// fallback), refund the narrate-then-stop rescue budget and (re-)append the
3317/// act-now continuation directive. The refund closes the counter/corrective
3318/// asymmetry: the spent-nudge counters are turn-locals that survive
3319/// compaction, while the corrective text they refer to lives in `messages`
3320/// and may have just been summarized away — leaving the harness refusing to
3321/// re-nudge a model that no longer remembers the correction. Prune-only and
3322/// fit passes keep the corrective text, so they neither refund nor anchor.
3323///
3324/// `mid_turn` (`round > 0` at the call sites) gates the directive: the
3325/// pre-dispatch compaction also fires on round 0 of a FRESH turn (a long
3326/// session's between-turn growth is first measured there), where "You are
3327/// mid-task … do not summarize" would be false and would countermand an
3328/// informational ask ("summarize what we changed today") sitting right above
3329/// it. At round 0 nothing has been spent or lost, so the whole repair is
3330/// skipped.
3331fn apply_post_compaction_continuation(
3332    messages: &mut Vec<serde_json::Value>,
3333    narration_nudges: &mut usize,
3334    action: CompressAction,
3335    step_ledger: Option<&dyn scheduled::StepLedger>,
3336    mid_turn: bool,
3337) {
3338    if !mid_turn
3339        || !matches!(
3340            action,
3341            CompressAction::Summarized | CompressAction::StaticFallback
3342        )
3343    {
3344        return;
3345    }
3346    *narration_nudges = 0;
3347    // At most one directive alive: drop any earlier copy before appending.
3348    messages.retain(|m| !compress::is_continuation_message(m));
3349    messages.push(serde_json::json!({
3350        "role": "user",
3351        "content": post_compaction_continuation(step_ledger),
3352    }));
3353}
3354
3355/// The stronger corrective for the second and later narration nudges
3356/// (`[tui] narration_nudge_cap` > 1). The generic first nudge already failed
3357/// to convert intent into action, so this one is state-driven like
3358/// [`read_only_action_nudge`]: it names the active plan step and demands the
3359/// next output be a bare tool call.
3360fn escalated_narration_action_nudge(
3361    attempt: usize,
3362    cap: usize,
3363    step_ledger: Option<&dyn scheduled::StepLedger>,
3364) -> String {
3365    let step_clause = active_step_description(step_ledger)
3366        .map(|step| format!(" Active step: '{step}'."))
3367        .unwrap_or_default();
3368    format!(
3369        "Reminder {attempt}/{cap}: you again described an action without calling \
3370         a tool, so nothing has happened.{step_clause} Your NEXT output must be \
3371         exactly one tool call that starts that action (for example read_file, \
3372         edit_file, or run_command with real arguments) — no prose before it. \
3373         If you are blocked, state the one concrete blocker in a single \
3374         sentence instead of announcing more intentions."
3375    )
3376}
3377
3378fn stale_file_ground_truth_nudge() -> String {
3379    "You claimed the file changed under you or that your edit context is stale, \
3380     but you did not prove that with ground truth. Before stopping or asking the \
3381     operator to restore/revert anything, run read-only verification: git status \
3382     --short, git diff -- <file>, wc -l <file>, and re-read the exact target \
3383     range. If those checks do not prove an actual concurrent change, continue \
3384     from the verified file contents. Never recommend git checkout/revert unless \
3385     git diff proves unwanted changes and the operator approves."
3386        .to_string()
3387}
3388
3389fn workflow_classifier_text(messages: &[serde_json::Value], current_content: &str) -> String {
3390    let mut parts = Vec::new();
3391    let start = messages.len().saturating_sub(12);
3392    for message in &messages[start..] {
3393        if let Some(text) = message_text(message) {
3394            if !text.trim().is_empty() {
3395                parts.push(text);
3396            }
3397        }
3398    }
3399    if !current_content.trim().is_empty() {
3400        parts.push(current_content.to_string());
3401    }
3402    parts.join("\n")
3403}
3404
3405fn combine_nudge_hints(first: Option<&str>, second: Option<&str>) -> Option<String> {
3406    let text = [first, second]
3407        .into_iter()
3408        .flatten()
3409        .map(str::trim)
3410        .filter(|hint| !hint.is_empty())
3411        .collect::<Vec<_>>()
3412        .join("\n\n");
3413    (!text.is_empty()).then_some(text)
3414}
3415
3416fn message_text(message: &serde_json::Value) -> Option<String> {
3417    match message.get("content")? {
3418        serde_json::Value::String(s) => Some(s.clone()),
3419        serde_json::Value::Array(parts) => {
3420            let text = parts
3421                .iter()
3422                .filter_map(|part| part.get("text").and_then(|text| text.as_str()))
3423                .collect::<Vec<_>>()
3424                .join("\n");
3425            (!text.is_empty()).then_some(text)
3426        }
3427        _ => None,
3428    }
3429}
3430
3431fn pending_plan_completion_nudge(
3432    step_ledger: Option<&dyn scheduled::StepLedger>,
3433    needs_plan_update: bool,
3434    workflow_hint: Option<&str>,
3435) -> Option<String> {
3436    let snapshot = step_ledger?.snapshot();
3437    let total = snapshot.steps.len();
3438    if total == 0 {
3439        return None;
3440    }
3441    let unfinished = snapshot
3442        .steps
3443        .iter()
3444        .filter(|s| s.status != StepStatus::Done)
3445        .count();
3446    if unfinished == 0 {
3447        return None;
3448    }
3449    let active = snapshot
3450        .steps
3451        .iter()
3452        .find(|s| s.status == StepStatus::Active)
3453        .or_else(|| snapshot.steps.iter().find(|s| s.status != StepStatus::Done));
3454    let active_clause = active
3455        .map(|s| format!(" Active step: '{}'.", s.description))
3456        .unwrap_or_default();
3457    let step_word = if unfinished == 1 { "step" } else { "steps" };
3458    let workflow_clause = workflow_hint
3459        .map(str::trim)
3460        .filter(|hint| !hint.is_empty())
3461        .map(|hint| format!("\n\n{hint}"))
3462        .unwrap_or_default();
3463    if needs_plan_update {
3464        Some(format!(
3465            "You ended with a findings/next-steps summary while the active plan still has \
3466             {unfinished}/{total} unfinished {step_word}.{active_clause} Your summary says \
3467             immediate prerequisite repair work now blocks the active step. Call update_plan now \
3468             with the full ordered plan: mark completed steps completed, make the immediate \
3469             blocker repair the active step, and keep later feature work pending. Then call the \
3470             next concrete tool for that active repair. Do not repeat the findings summary or \
3471             claim a tool-call limit while this nudge is giving you another round.{workflow_clause}"
3472        ))
3473    } else {
3474        Some(format!(
3475            "You ended the turn while the active plan still has {unfinished}/{total} unfinished \
3476             {step_word}.{active_clause} Either call update_plan with completed steps marked \
3477             completed, call the next tool for the active step, or state the concrete blocker. \
3478             Do not hand off by only describing remaining work."
3479        ))
3480    }
3481}
3482
3483fn read_only_action_nudge(
3484    read_only_rounds: usize,
3485    remaining_rounds: usize,
3486    step_ledger: Option<&dyn scheduled::StepLedger>,
3487    delegate_hint: Option<&str>,
3488) -> String {
3489    let plan_clause = if step_ledger.and_then(plan_reseat_pointer).is_some() {
3490        " You have an active multi-step plan; keep working the ACTIVE step instead of \
3491         restarting or re-planning."
3492    } else {
3493        ""
3494    };
3495    let delegate_clause = delegate_hint
3496        .map(|hint| format!(" {hint}"))
3497        .unwrap_or_default();
3498    format!(
3499        "[{read_only_rounds} read-only rounds so far. Stop AIMLESS exploring and start \
3500         making the change. This is a nudge, not a limit — you may still read, but if \
3501         you have enough context, call edit_file or write_file now. If a capability \
3502         denial blocks you, call request_permissions with the exact capability and \
3503         target, or take a different approach. If you truly cannot edit yet, state the \
3504         exact blocker. Before edit_file, read the ONE file you are about to change so \
3505         old_string matches exact text; never guess old_string or repeat a failed edit.\
3506         {plan_clause}{delegate_clause} ~{remaining_rounds} round(s) left.]"
3507    )
3508}
3509
3510/// Append the memory-nudge line to the current user message — the last
3511/// message in the list per the memory-manager contract. Defensive fallback:
3512/// if the last message somehow isn't a user turn, push a standalone user
3513/// message instead (mirrors the read-only-rounds nudge injection).
3514fn append_nudge_line(messages: &mut Vec<serde_json::Value>, line: &str) {
3515    match messages.last_mut() {
3516        Some(last) if last["role"] == "user" => {
3517            let cur = last["content"].as_str().unwrap_or_default();
3518            last["content"] = serde_json::Value::String(format!("{cur}\n\n{line}"));
3519        }
3520        _ => messages.push(serde_json::json!({"role": "user", "content": line})),
3521    }
3522}
3523
3524fn ollama_non_content_fields(json: &serde_json::Value) -> Vec<&'static str> {
3525    let message = &json["message"];
3526    ["reasoning", "reasoning_content", "thinking"]
3527        .into_iter()
3528        .filter(|field| {
3529            message[*field]
3530                .as_str()
3531                .map(|value| !value.trim().is_empty())
3532                .unwrap_or(false)
3533        })
3534        .collect()
3535}
3536
3537fn ollama_response_shape(json: &serde_json::Value) -> String {
3538    let message = &json["message"];
3539    let message_keys = message
3540        .as_object()
3541        .map(|obj| obj.keys().cloned().collect::<Vec<_>>().join(","))
3542        .unwrap_or_else(|| "<missing>".to_string());
3543    let content_chars = message["content"]
3544        .as_str()
3545        .map(|content| content.chars().count())
3546        .unwrap_or(0);
3547    let tool_calls = message["tool_calls"]
3548        .as_array()
3549        .map(|calls| calls.len())
3550        .unwrap_or(0);
3551    let non_content = ollama_non_content_fields(json);
3552    let non_content = if non_content.is_empty() {
3553        "none".to_string()
3554    } else {
3555        non_content.join(",")
3556    };
3557    format!(
3558        "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={}",
3559        json["prompt_eval_count"]
3560            .as_u64()
3561            .map_or("missing".to_string(), |n| n.to_string()),
3562        json["eval_count"]
3563            .as_u64()
3564            .map_or("missing".to_string(), |n| n.to_string())
3565    )
3566}
3567
3568fn suspicious_empty_retry_nudge(retry_index: u32, json: &serde_json::Value) -> String {
3569    if retry_index == 0 {
3570        return "Your previous response produced generated tokens but no assistant-visible content \
3571                and no tool call. Reply with either a tool call or final assistant content."
3572            .to_string();
3573    }
3574    let fields = ollama_non_content_fields(json);
3575    let field_note = if fields.is_empty() {
3576        "hidden/non-content fields".to_string()
3577    } else {
3578        format!("hidden/non-content field(s): {}", fields.join(", "))
3579    };
3580    format!(
3581        "Your previous response again produced generated tokens only in {field_note}, \
3582         with no assistant-visible content and no tool call. Hidden thinking is not an \
3583         action. If you intend to act, emit the exact tool call now; otherwise reply \
3584         with final assistant-visible content. Do not continue with hidden-only reasoning."
3585    )
3586}
3587
3588fn suspicious_empty_ollama_diagnostic(json: &serde_json::Value) -> String {
3589    let fields = ollama_non_content_fields(json);
3590    let field_note = if fields.is_empty() {
3591        "no known non-content fields were present".to_string()
3592    } else {
3593        format!("non-content field(s) present: {}", fields.join(", "))
3594    };
3595    format!(
3596        "(model generated output tokens but returned no assistant-visible content or tool calls; {field_note}; rerun with `newt --trace` to capture the response shape)"
3597    )
3598}
3599
3600/// Build the nudge appended to the message list when the tool-round cap is hit.
3601/// `progress` (the `<plan>`/`<state>` working memory, Step 27.5) is folded in so
3602/// the model summarizes against what it actually accomplished; `observed`
3603/// (#867 Part A) is the verified-paths manifest collected across the rounds.
3604fn cap_exit_nudge(max_tool_rounds: usize, progress: Option<&str>, observed: &[String]) -> String {
3605    // #867: the message list was just trimmed (`trim_for_summary`), so most
3606    // of the evidence this summary should cite is GONE — the forensic session
3607    // showed a model reconstructing plausible-but-nonexistent file paths from
3608    // its priors at exactly this point. Constrain the summary to what is
3609    // still verbatim in context; absence must be stated, not papered over.
3610    let mut nudge = format!(
3611        "You have reached the tool-call limit ({max_tool_rounds} rounds). \
3612         Do NOT call any more tools. Summarize what you found across the tool \
3613         calls above and give your best final answer now. Cite only file paths \
3614         that appear verbatim in the messages above — if the evidence you need \
3615         was in the omitted messages, say so plainly instead of reconstructing \
3616         file names or line numbers from memory. Do not answer with an intention \
3617         to keep working (for example, \"let me read/edit/verify\"); if work remains, \
3618         list it as remaining work and state that the round cap stopped further tool calls."
3619    );
3620    // #867 Part A: the ledger survived the trim — hand the model the REAL
3621    // manifest so grounded citation is possible, not just demanded.
3622    if !observed.is_empty() {
3623        nudge.push_str(
3624            "\n\nFile paths actually observed in tool results this run \
3625             (these exist — cite from this list):",
3626        );
3627        for p in observed {
3628            nudge.push_str("\n- ");
3629            nudge.push_str(p);
3630        }
3631    }
3632    if let Some(p) = progress {
3633        nudge.push_str(&format!("\n\nYour progress so far:\n{p}"));
3634    }
3635    nudge
3636}
3637
3638/// Fallback message returned when even the final tools-disabled completion
3639/// fails. Includes accumulated token counts, salvages the `<plan>`/`<state>`
3640/// progress so partial work survives (Step 27.5), and gives HONEST advice: a run
3641/// dominated by failed tool calls is a tooling/permissions problem, not too few
3642/// rounds, so we don't blindly tell the user to raise the cap.
3643fn cap_exit_tokens_hint(max_tool_rounds: usize, accumulated: Option<crate::TokenUsage>) -> String {
3644    match accumulated {
3645        Some(u) => format!(
3646            " ({} in / {} out tokens consumed across {max_tool_rounds} rounds)",
3647            u.input_tokens, u.output_tokens,
3648        ),
3649        None => String::new(),
3650    }
3651}
3652
3653fn cap_exit_advice(max_tool_rounds: usize, wasted_calls: usize) -> &'static str {
3654    // If at least one failed tool call per round, the cap was thrash, not a
3655    // genuine need for more rounds.
3656    if wasted_calls >= max_tool_rounds.max(1) {
3657        "most of those rounds were spent on tool calls that failed — the model \
3658         could not find a working edit/shell path, which is usually a tooling or \
3659         permissions issue rather than too few rounds; check `newt doctor`"
3660    } else {
3661        "raise [tui].max_tool_rounds in your config, or ask a more focused question"
3662    }
3663}
3664
3665fn cap_exit_progress_block(label: &str, progress: Option<&str>) -> String {
3666    match progress {
3667        Some(p) => format!("\n\n{label}:\n{p}"),
3668        None => String::new(),
3669    }
3670}
3671
3672fn cap_exit_fallback(
3673    max_tool_rounds: usize,
3674    accumulated: Option<crate::TokenUsage>,
3675    wasted_calls: usize,
3676    progress: Option<&str>,
3677) -> String {
3678    let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3679    let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3680    let salvaged = cap_exit_progress_block("Progress captured before the summary failed", progress);
3681    format!(
3682        "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}, \
3683         and the final summarization request also failed — {advice}){salvaged}"
3684    )
3685}
3686
3687fn cap_exit_action_handoff_fallback(
3688    max_tool_rounds: usize,
3689    accumulated: Option<crate::TokenUsage>,
3690    wasted_calls: usize,
3691    progress: Option<&str>,
3692) -> String {
3693    let tokens_hint = cap_exit_tokens_hint(max_tool_rounds, accumulated);
3694    let advice = cap_exit_advice(max_tool_rounds, wasted_calls);
3695    let salvaged = cap_exit_progress_block("Progress captured at the tool-call limit", progress);
3696    format!(
3697        "(reached the tool-call limit of {max_tool_rounds} rounds{tokens_hint}; \
3698         the final tools-disabled summary described future tool actions instead \
3699         of final state, so Newt preserved the verified progress instead of \
3700         accepting that handoff — {advice}){salvaged}"
3701    )
3702}
3703
3704fn cap_exit_summary_is_action_handoff(content: &str) -> bool {
3705    crate::NudgeClassifier::load_default()
3706        .classify(content)
3707        .class
3708        == crate::NudgeClass::PendingAction
3709        || looks_like_unverified_stale_file_blocker(content)
3710}
3711
3712/// The cap-exit context threaded into a final tools-disabled summary (Step
3713/// 27.5): the round limit, accumulated usage, the count of failed tool calls
3714/// (drives honest advice), the salvaged `<plan>`/`<state>` progress, and the
3715/// #867 observed-paths manifest (verified paths from tool results, collected
3716/// before the trim could delete them).
3717struct CapExit {
3718    max_tool_rounds: usize,
3719    accumulated: Option<crate::TokenUsage>,
3720    wasted_calls: usize,
3721    progress: Option<String>,
3722    observed: Vec<String>,
3723}
3724
3725/// Final tools-disabled completion for the Ollama (`/api/chat`) path.
3726///
3727/// `messages` is the already-trimmed list (caller uses `trim_for_summary`).
3728/// `cap.accumulated` carries usage from the preceding tool-call rounds so it
3729/// survives even when this summary request fails.
3730async fn final_summary_ollama(
3731    client: &reqwest::Client,
3732    chat_url: &str,
3733    model: &str,
3734    mut messages: Vec<serde_json::Value>,
3735    cap: CapExit,
3736) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3737    let CapExit {
3738        max_tool_rounds,
3739        accumulated,
3740        wasted_calls,
3741        progress,
3742        observed,
3743    } = cap;
3744    messages.push(serde_json::json!({
3745        "role": "user",
3746        "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3747    }));
3748    // No `tools` key => the model cannot emit tool calls.
3749    let body = serde_json::json!({
3750        "model": model,
3751        "messages": &messages,
3752        "stream": false,
3753    });
3754    let retry = tui_retry_policy();
3755    let result = with_backoff_notify(
3756        &retry,
3757        || async {
3758            let resp = client
3759                .post(chat_url)
3760                .json(&body)
3761                .send()
3762                .await
3763                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3764            if !resp.status().is_success() {
3765                let status = resp.status();
3766                let text = resp.text().await.unwrap_or_default();
3767                anyhow::bail!("Ollama {status}: {text}");
3768            }
3769            resp.json::<serde_json::Value>()
3770                .await
3771                .map_err(anyhow::Error::from)
3772        },
3773        |_, _| {}, // no color context here; tracing::warn covers it
3774    )
3775    .await;
3776    match result {
3777        Ok(json) => {
3778            // #385: strip inline <think>…</think> reasoning Nemotron-style models emit
3779            // in the content stream (the separate `thinking` field is handled elsewhere).
3780            // All-reasoning content collapses to empty → the thinking-only recovery below.
3781            let (content, _reasoning) = crate::reasoning::split_reasoning(
3782                json["message"]["content"].as_str().unwrap_or(""),
3783            );
3784            let total = merge_round_usage(accumulated, ollama_usage(&json));
3785            if content.is_empty() {
3786                Ok((
3787                    cap_exit_fallback(
3788                        max_tool_rounds,
3789                        accumulated,
3790                        wasted_calls,
3791                        progress.as_deref(),
3792                    ),
3793                    false,
3794                    accumulated,
3795                ))
3796            } else if cap_exit_summary_is_action_handoff(&content) {
3797                Ok((
3798                    cap_exit_action_handoff_fallback(
3799                        max_tool_rounds,
3800                        accumulated,
3801                        wasted_calls,
3802                        progress.as_deref(),
3803                    ),
3804                    false,
3805                    total,
3806                ))
3807            } else {
3808                Ok((content, false, total))
3809            }
3810        }
3811        // On any failure (including exhausted retries), still return the
3812        // accumulated usage so the caller can log the tokens consumed.
3813        Err(_) => Ok((
3814            cap_exit_fallback(
3815                max_tool_rounds,
3816                accumulated,
3817                wasted_calls,
3818                progress.as_deref(),
3819            ),
3820            false,
3821            accumulated,
3822        )),
3823    }
3824}
3825
3826/// Final tools-disabled completion for the OpenAI (`/v1/chat/completions`) path.
3827///
3828/// `messages` is the already-trimmed list (caller uses `trim_for_summary`).
3829/// `accumulated` carries usage from the preceding tool-call rounds.
3830async fn final_summary_openai(
3831    client: &reqwest::Client,
3832    chat_url: &str,
3833    model: &str,
3834    api_key: Option<&str>,
3835    mut messages: Vec<serde_json::Value>,
3836    cap: CapExit,
3837) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>)> {
3838    let CapExit {
3839        max_tool_rounds,
3840        accumulated,
3841        wasted_calls,
3842        progress,
3843        observed,
3844    } = cap;
3845    messages.push(serde_json::json!({
3846        "role": "user",
3847        "content": cap_exit_nudge(max_tool_rounds, progress.as_deref(), &observed),
3848    }));
3849    // Omit `tools` / `tool_choice` => the model cannot emit tool calls.
3850    let body = serde_json::json!({
3851        "model": model,
3852        "messages": &messages,
3853        "stream": false,
3854    });
3855    let retry = tui_retry_policy();
3856    let result = with_backoff_notify(
3857        &retry,
3858        || async {
3859            let mut req = client.post(chat_url).json(&body);
3860            if let Some(key) = api_key {
3861                req = req.bearer_auth(key);
3862            }
3863            let resp = req
3864                .send()
3865                .await
3866                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
3867            if !resp.status().is_success() {
3868                let status = resp.status();
3869                let text = resp.text().await.unwrap_or_default();
3870                anyhow::bail!("inference endpoint {status}: {text}");
3871            }
3872            resp.json::<serde_json::Value>()
3873                .await
3874                .map_err(anyhow::Error::from)
3875        },
3876        |_, _| {},
3877    )
3878    .await;
3879    match result {
3880        Ok(json) => {
3881            // #385: strip inline <think>…</think> reasoning from the content.
3882            let (content, _reasoning) = crate::reasoning::split_reasoning(
3883                json["choices"][0]["message"]["content"]
3884                    .as_str()
3885                    .unwrap_or(""),
3886            );
3887            let total = merge_round_usage(accumulated, openai_usage(&json["usage"]));
3888            if content.is_empty() {
3889                Ok((
3890                    cap_exit_fallback(
3891                        max_tool_rounds,
3892                        accumulated,
3893                        wasted_calls,
3894                        progress.as_deref(),
3895                    ),
3896                    false,
3897                    accumulated,
3898                ))
3899            } else if cap_exit_summary_is_action_handoff(&content) {
3900                Ok((
3901                    cap_exit_action_handoff_fallback(
3902                        max_tool_rounds,
3903                        accumulated,
3904                        wasted_calls,
3905                        progress.as_deref(),
3906                    ),
3907                    false,
3908                    total,
3909                ))
3910            } else {
3911                Ok((content, false, total))
3912            }
3913        }
3914        Err(_) => Ok((
3915            cap_exit_fallback(
3916                max_tool_rounds,
3917                accumulated,
3918                wasted_calls,
3919                progress.as_deref(),
3920            ),
3921            false,
3922            accumulated,
3923        )),
3924    }
3925}
3926
3927/// OpenAI-compatible variant of [`chat_complete`]: the same agentic tool-call
3928/// loop, but over `POST {endpoint}/v1/chat/completions` with bearer auth and
3929/// the OpenAI `tool_calls` / `tool_call_id` / `usage` shapes.
3930///
3931/// Non-streaming for now — the final answer is returned (and printed by the
3932/// caller) rather than streamed token-by-token. Token-by-token SSE streaming
3933/// is a follow-up; functionally the loop is complete, including tools.
3934pub async fn openai_chat_complete(
3935    ctx: ChatCtx<'_>,
3936    mcp: &mut dyn McpTools,
3937) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
3938    let ChatCtx {
3939        url,
3940        model,
3941        kind: _,
3942        api_key,
3943        messages: mem_messages,
3944        task,
3945        workspace,
3946        color,
3947        markdown: _,
3948        tool_offload,
3949        spill_store,
3950        compaction_store,
3951        scratchpad,
3952        scratchpad_store,
3953        code_search,
3954        experience_store,
3955        step_ledger,
3956        caveats,
3957        persona_tools,
3958        max_tool_rounds,
3959        workflow_grace_rounds,
3960        narration_nudge_cap,
3961        tool_output_lines,
3962        debug,
3963        trace,
3964        num_ctx,
3965        connect_timeout_secs,
3966        inference_timeout_secs,
3967        mid_loop_trim_threshold,
3968        mid_loop_trim_tokens,
3969        max_ok_input,
3970        build_check_cmd,
3971        safe_context,
3972        recover_cw_400,
3973        mut note_sink,
3974        mut note_nudge,
3975        recall_source,
3976        memory_source,
3977        summarizer,
3978        compress_state,
3979        mut tool_events,
3980        mut phantom_reaches,
3981        mut end_reason,
3982        mut permission_gate,
3983        mut on_round_usage,
3984        estimate_ratio,
3985        estimation,
3986        summary_input_cap_floor_chars,
3987        input_ceiling_pct,
3988        low_budget_pct,
3989        exec_floor,
3990        write_ledger,
3991        cancel,
3992        git_tool,
3993        crew_runner,
3994    } = ctx;
3995    // Headless callers may pass no session state (mirrors the Ollama path).
3996    let mut local_compress_state = CompressState::new();
3997    let compress_state = match compress_state {
3998        Some(s) => s,
3999        None => &mut local_compress_state,
4000    };
4001    let client = reqwest::Client::builder()
4002        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
4003        .timeout(std::time::Duration::from_secs(inference_timeout_secs))
4004        .build()?;
4005    let chat_url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
4006    let retry = tui_retry_policy();
4007    // The save_note tool is advertised only when a sink exists (Step 19.3);
4008    // recall only when a source exists (Step 17.5); memory_fetch only when a
4009    // memory source exists (#319) — mirrors the Ollama path.
4010    let advertise_save_note = note_sink.is_some();
4011    let advertise_recall = recall_source.is_some();
4012    let advertise_memory_fetch = memory_source.is_some();
4013    // Step 26.4 (#583): state tools only when the feature is on AND a store exists.
4014    let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
4015    // Step 26.5.5 (#582): the code_search tool when a searcher is present.
4016    let advertise_code_search = code_search.is_some();
4017    // Step 26.6a (#585): the experiential tools when a store is present.
4018    let advertise_experiential = experience_store.is_some();
4019    // Step 26.6b (#586): the scheduled plan tools when a ledger is present.
4020    let advertise_scheduled = step_ledger.is_some();
4021    let advertise_git = git_tool.is_some();
4022    let advertise_team = crew_runner.is_some();
4023
4024    let mut messages: Vec<serde_json::Value> = mem_messages
4025        .iter()
4026        .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
4027        .collect();
4028
4029    // In-band memory nudge (Step 19.3) — mirrors the Ollama path.
4030    if note_sink.is_some() {
4031        if let Some(line) = note_nudge.as_deref_mut().and_then(NoteNudge::begin_turn) {
4032            append_nudge_line(&mut messages, &line);
4033        }
4034    }
4035
4036    let mut accumulated_usage: Option<crate::TokenUsage> = None;
4037    let mut hallucination_count: u32 = 0;
4038    // Step 27.3/#771: guard against exact-repeat tool loops this run.
4039    let mut repeat_calls = RepeatCallGuard::default();
4040    // Hard context-window 400s recovered (parse limit → trim → retry). See #223.
4041    let mut cw_retries: u32 = 0;
4042    // No-tools recovery (mirrors the Ollama path): a model that rejects the
4043    // `tools` field 400s even on "hello"; drop tools and retry, notice once.
4044    let mut tools_supported = true;
4045    let mut tools_unsupported_notified = false;
4046    // Pre-send token budget gate; tightened mid-turn by a recovered 400
4047    // (#223). Phase 20 §2.1 max(proven, believed) semantics — no `num_ctx`
4048    // ceiling on this wire (limits are server-side, e.g. vLLM
4049    // --max-model-len), so the ceiling leg is `None`.
4050    let mut send_budget: Option<usize> =
4051        initial_send_budget(max_ok_input, safe_context, None, input_ceiling_pct);
4052    // Step 20.3: on this wire there is no `num_ctx` ceiling, so the send
4053    // budget is authoritative only when a believed window (`safe_context`)
4054    // seeds it. Cloud OpenAI-compatible models have no `/api/show` to seed
4055    // one, so their budget rests on the proven-good HWM alone — the guard
4056    // fails open rather than refusing. A cw-400 flips this true mid-turn.
4057    let mut send_budget_authoritative = safe_context.is_some();
4058    // Tool schemas ride along in every request body; count them once (18.1).
4059    let tools = merged_tool_definitions(
4060        mcp,
4061        advertise_save_note,
4062        advertise_recall,
4063        advertise_memory_fetch,
4064        advertise_git,
4065        advertise_team,
4066        advertise_scratchpad,
4067        advertise_code_search,
4068        advertise_experiential,
4069        advertise_scheduled,
4070    );
4071    // FR-1 part 2 (#997): scope the advertised catalog to the active persona's
4072    // `tools:` allow-list (no-op when `persona_tools` is `None`). The executor
4073    // enforces the same set, so what the model sees and what it may run agree.
4074    let tools = filter_advertised_tools(tools, persona_tools);
4075    let tool_tokens = estimate_value_tokens(&tools, estimation);
4076    // Phase 20 §2.3: per-turn calibration ratio + real-token schema overhead
4077    // (mirrors the Ollama path).
4078    let cal = sanitize_estimate_ratio(estimate_ratio);
4079    let tool_tokens_real = calibrate_up(tool_tokens, cal);
4080    // Truthful context-size tracker (prompt-tokens-preferred, Step 18.1).
4081    let mut prompt_tracker = PromptTracker::new();
4082    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
4083
4084    // #867 Part A: observed-paths ledger (matches the Ollama path).
4085    let mut observed_paths = claim_check::ObservedPaths::default();
4086    let observed_resolver = claim_check::workspace_resolver(workspace);
4087
4088    // Narrate-then-stop rescue counter (mirror of the Ollama path).
4089    let mut narration_nudges: usize = 0;
4090    // Pending-plan final-answer gate counter (mirror of the Ollama path).
4091    let mut pending_plan_nudges: usize = 0;
4092    // Unverified stale-file blocker rescue counter (mirror of the Ollama path).
4093    let mut stale_file_nudges: usize = 0;
4094    let nudge_classifier = crate::NudgeClassifier::load_default();
4095    let workflow_steerer = crate::WorkflowSteerer::load_default();
4096    let mut workflow_runtime = WorkflowRuntimeState::default();
4097    // See the Ollama path: a matching workflow's grace-horizon override.
4098    workflow_runtime.set_progress_horizon(
4099        workflow_steerer.progress_horizon(&workflow_classifier_text(&messages, "")),
4100    );
4101
4102    // Agentic loop — up to `max_tool_rounds` tool-call rounds (matches the
4103    // Ollama path), plus a configurable workflow grace window when the normal
4104    // cap would stop during active workflow progress.
4105    let hard_tool_rounds = max_tool_rounds.saturating_add(workflow_grace_rounds);
4106    let mut workflow_grace_active = false;
4107    let mut current_tool_round_limit = max_tool_rounds;
4108    'round_loop: for round in 0..hard_tool_rounds {
4109        if round >= current_tool_round_limit {
4110            if workflow_grace_active {
4111                break;
4112            }
4113            let Some(nudge) = workflow_runtime.cap_grace_nudge(
4114                step_ledger,
4115                max_tool_rounds,
4116                workflow_grace_rounds,
4117            ) else {
4118                break;
4119            };
4120            workflow_grace_active = true;
4121            current_tool_round_limit = hard_tool_rounds;
4122            if debug {
4123                print_debug(
4124                    "workflow progress at soft round cap — granting configured grace window",
4125                    color,
4126                );
4127            }
4128            messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4129        }
4130        // Interrupt checkpoint (Esc / Ctrl-C), same contract as the Ollama path:
4131        // bail at the round boundary with an empty reply; the caller treats the
4132        // turn as abandoned. (The OpenAI path's per-request awaits are not yet
4133        // individually raced — round granularity is enough for the opt-in
4134        // provider plugin; the Ollama first-class path cancels mid-await.)
4135        if is_cancelled(cancel) {
4136            return Ok((String::new(), false, accumulated_usage, hallucination_count));
4137        }
4138        if round > 0 && color {
4139            execute!(
4140                io::stdout(),
4141                SetForegroundColor(CtColor::DarkGrey),
4142                Print("…\n"),
4143                ResetColor
4144            )
4145            .ok();
4146        }
4147
4148        // Conditional plan re-seat (#630 b) — mirror of the Ollama path: re-show
4149        // the active step each round so a multi-step plan doesn't go stale.
4150        if round > 0 {
4151            if let Some(ptr) = step_ledger.and_then(plan_reseat_pointer) {
4152                messages.push(serde_json::json!({ "role": "user", "content": ptr }));
4153            }
4154            if let Some(nudge) = workflow_runtime.round_start_nudge(step_ledger) {
4155                messages.push(serde_json::json!({ "role": "user", "content": nudge }));
4156            }
4157        }
4158
4159        // Context compression (Step 18.4, #247 — mirrors the Ollama path):
4160        // the shared prune → boundary → redacted summary → marker pipeline
4161        // serves the mid-loop trigger and the pre-send budget guard.
4162        {
4163            // Phase 20 §2.3: calibrated `current` (real-token space) —
4164            // mirrors the Ollama path.
4165            let current = prompt_tracker.current(&messages, Some(&tools), cal, estimation);
4166            // Count-only budget priced in message-token space (F1) — mirrors
4167            // the Ollama path.
4168            let message_tokens = estimate_tokens(&messages, estimation);
4169            if let Some(trigger) = compression_trigger(
4170                messages.len(),
4171                current,
4172                message_tokens,
4173                mid_loop_trim_threshold,
4174                mid_loop_trim_tokens,
4175                send_budget,
4176                tool_tokens_real,
4177            ) {
4178                // Hard budgets are real-token currency → pipeline chars/4
4179                // (Phase 20 §2.3); count-only budgets pass through (F1).
4180                let pipeline_budget = if trigger.hard_budget {
4181                    calibrate_down(trigger.budget, cal)
4182                } else {
4183                    trigger.budget
4184                };
4185                // Step 20.3: authoritative iff a token threshold fired or the
4186                // send budget rests on a believed ceiling (mirrors the Ollama
4187                // loop). A lone-HWM guard is non-authoritative → fails open.
4188                let token_fired = mid_loop_trim_tokens.is_some_and(|t| t > 0 && current > t);
4189                let outcome = compress(
4190                    CompressRequest {
4191                        messages: &messages,
4192                        budget: pipeline_budget,
4193                        max_messages: trigger.max_messages,
4194                        task,
4195                        hard_budget: trigger.hard_budget,
4196                        authoritative: token_fired || send_budget_authoritative,
4197                        focus: None,
4198                        est: estimation,
4199                        summary_input_cap_floor_chars,
4200                        compaction_store,
4201                    },
4202                    summarizer,
4203                    compress_state,
4204                )
4205                .await;
4206                if let Some(notice) = outcome.notice {
4207                    print_harness_notice(&notice, color);
4208                }
4209                if outcome.action == CompressAction::Refused {
4210                    anyhow::bail!(
4211                        "context (~{current} tokens) exceeds the model's input budget and \
4212                         auto-compression is disabled after repeated ineffective passes — \
4213                         start a new conversation or ask a more focused question, or run \
4214                         `newt tunings reset {model}` if this model's learned budget looks wrong"
4215                    );
4216                }
4217                if outcome.fired {
4218                    // N2 (mirrors the Ollama path): flag a still-over-budget
4219                    // assembly in the notice — compared in the pipeline's
4220                    // own chars/4 currency (Phase 20 §2.3).
4221                    let suffix = if trigger.hard_budget && outcome.tokens_after > pipeline_budget {
4222                        ", still over budget"
4223                    } else {
4224                        ""
4225                    };
4226                    emit_compression_notice(
4227                        color,
4228                        outcome.tokens_before,
4229                        outcome.tokens_after,
4230                        outcome.action,
4231                        suffix,
4232                    );
4233                    if debug {
4234                        print_debug(
4235                            &format!(
4236                                "compression: {} → {} messages (budget ~{} tokens, \
4237                                 +~{tool_tokens} tool-schema tokens ride along)",
4238                                messages.len(),
4239                                outcome.messages.len(),
4240                                pipeline_budget,
4241                            ),
4242                            color,
4243                        );
4244                    }
4245                    messages = outcome.messages;
4246                    prompt_tracker.invalidate();
4247                    apply_post_compaction_continuation(
4248                        &mut messages,
4249                        &mut narration_nudges,
4250                        outcome.action,
4251                        step_ledger,
4252                        round > 0,
4253                    );
4254                }
4255            }
4256        }
4257
4258        // Phase 20 §2.2: chars/4 estimate of exactly the request about to be
4259        // dispatched — mirrors the Ollama path.
4260        let round_est_raw = estimate_request_tokens(&messages, Some(&tools), estimation);
4261
4262        // OpenAI-compatible endpoints don't use Ollama's `options.num_ctx` —
4263        // context limits are configured server-side (vLLM --max-model-len).
4264        let mut body = serde_json::json!({
4265            "model": model,
4266            "messages": messages,
4267            "tools": tools.clone(),
4268            "tool_choice": "auto",
4269            "stream": false,
4270        });
4271        // Drop tools (and the now-meaningless tool_choice) for a model that
4272        // rejected them on a prior "does not support tools" 400.
4273        if !tools_supported {
4274            if let Some(o) = body.as_object_mut() {
4275                o.remove("tools");
4276                o.remove("tool_choice");
4277            }
4278        }
4279        // No `num_ctx` is sent here, so #282's per-request input ceiling has
4280        // no value to key on either — the pre-send guard stays on the cached
4281        // `max_ok_input` ∥ `safe_context` numbers and the cw-400 recovery
4282        // (these endpoints DO reject oversize requests with a parseable 400,
4283        // unlike Ollama's silent truncation).
4284        let _ = num_ctx; // not applicable for OpenAI-compatible endpoints
4285        let dispatch = with_backoff_notify(
4286            &retry,
4287            || async {
4288                let mut req = client.post(&chat_url).json(&body);
4289                if let Some(key) = api_key {
4290                    req = req.bearer_auth(key);
4291                }
4292                let resp = req
4293                    .send()
4294                    .await
4295                    .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
4296                if !resp.status().is_success() {
4297                    let status = resp.status();
4298                    let text = resp.text().await.unwrap_or_default();
4299                    anyhow::bail!("inference endpoint {status}: {text}");
4300                }
4301                resp.json::<serde_json::Value>()
4302                    .await
4303                    .map_err(anyhow::Error::from)
4304            },
4305            |attempt, delay| print_retry_indicator(attempt, delay, color),
4306        )
4307        .await;
4308        let json: serde_json::Value = match dispatch {
4309            Ok(j) => j,
4310            Err(e) => {
4311                // No-tools recovery: a model that rejects the `tools` field
4312                // (deepseek-r1) 400s even on "hello". Drop tools, notice once,
4313                // and re-dispatch the same round — self-limiting (the rebuilt
4314                // body omits tools) and session-persistent.
4315                if tools_supported && is_tools_unsupported_error(&e) {
4316                    tools_supported = false;
4317                    if !tools_unsupported_notified {
4318                        tools_unsupported_notified = true;
4319                        print_newt(
4320                            &format!(
4321                                "{model} does not support tools — tools disabled for this session"
4322                            ),
4323                            color,
4324                            false,
4325                        );
4326                    }
4327                    continue 'round_loop;
4328                }
4329                // Graceful context-window 400 recovery: parse the model's real
4330                // limit, tighten the budget, compress, and retry once (#223;
4331                // compress-not-trim since Step 18.4).
4332                if cw_retries < 2 {
4333                    if let Some(new_cap) = recover_cw_400.and_then(|f| f(&e, model, &today)) {
4334                        emit_overflow_notice(
4335                            color,
4336                            accumulated_usage.as_ref(),
4337                            Some(new_cap),
4338                            model,
4339                            cw_retries + 1,
4340                        );
4341                        send_budget = Some(new_cap as usize);
4342                        // The endpoint's parsed hard limit is authoritative
4343                        // from here on (Step 20.3; mirrors the Ollama path).
4344                        send_budget_authoritative = true;
4345                        let outcome = compress(
4346                            CompressRequest {
4347                                // Real-token cap minus real-token schema
4348                                // overhead → pipeline chars/4 currency
4349                                // (Phase 20 §2.3; mirrors the Ollama path).
4350                                messages: &messages,
4351                                budget: calibrate_down(
4352                                    (new_cap as usize).saturating_sub(tool_tokens_real),
4353                                    cal,
4354                                ),
4355                                max_messages: None,
4356                                task,
4357                                hard_budget: true,
4358                                authoritative: true,
4359                                focus: None,
4360                                est: estimation,
4361                                summary_input_cap_floor_chars,
4362                                compaction_store,
4363                            },
4364                            summarizer,
4365                            compress_state,
4366                        )
4367                        .await;
4368                        if let Some(notice) = outcome.notice {
4369                            print_harness_notice(&notice, color);
4370                        }
4371                        if outcome.action == CompressAction::Refused {
4372                            // Refuse the resend; surface the endpoint's 400.
4373                            return Err(e);
4374                        }
4375                        if outcome.fired {
4376                            messages = outcome.messages;
4377                            prompt_tracker.invalidate();
4378                            apply_post_compaction_continuation(
4379                                &mut messages,
4380                                &mut narration_nudges,
4381                                outcome.action,
4382                                step_ledger,
4383                                round > 0,
4384                            );
4385                        }
4386                        cw_retries += 1;
4387                        continue 'round_loop;
4388                    }
4389                }
4390                return Err(e);
4391            }
4392        };
4393        // Merge per-round token usage (input = max single prompt, output =
4394        // sum — Step 18.1) and anchor the context-size tracker.
4395        let round_usage = openai_usage(&json["usage"]);
4396        if let Some(u) = round_usage {
4397            prompt_tracker.record(u.input_tokens, messages.len());
4398        }
4399        accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
4400
4401        // Phase 20 §2.2: no `num_ctx` on this wire, so there is no silent
4402        // head-truncation mode to suspect (oversize requests get a parseable
4403        // 400 instead) and no per-request ceiling on the mid-turn raise.
4404        let truncation_suspect = false;
4405        if let (Some(u), Some(budget), false) = (round_usage, send_budget, truncation_suspect) {
4406            let raised = u.input_tokens as usize;
4407            if raised > budget {
4408                send_budget = Some(raised);
4409                if debug {
4410                    print_debug(
4411                        &format!(
4412                            "send budget raised to ~{raised} tokens (backend accepted \
4413                             {}-token prompt)",
4414                            u.input_tokens
4415                        ),
4416                        color,
4417                    );
4418                }
4419            }
4420        }
4421
4422        let message = &json["choices"][0]["message"];
4423
4424        // #857: split the reasoning OFF the answer. A `<think>` block (reasoning
4425        // parser off) must never be returned or fed to the content-scrape recovery,
4426        // and the separate `reasoning_content` channel (reasoning parser on) is read
4427        // but never concatenated into the reply. Normal replies (no reasoning) are
4428        // unchanged: `split_reasoning` returns the content verbatim.
4429        let (oa_content, inline_reasoning) =
4430            crate::reasoning::split_reasoning(message["content"].as_str().unwrap_or(""));
4431        let separate_reasoning = message["reasoning_content"]
4432            .as_str()
4433            .map(str::trim)
4434            .filter(|s| !s.is_empty());
4435        if debug && (separate_reasoning.is_some() || inline_reasoning.is_some()) {
4436            let n = separate_reasoning
4437                .map(str::len)
4438                .or_else(|| inline_reasoning.as_deref().map(str::len))
4439                .unwrap_or(0);
4440            print_debug(
4441                &format!("reasoning ({n} chars) surfaced to the trace, not the answer"),
4442                color,
4443            );
4444        }
4445        let native_calls = message["tool_calls"].as_array();
4446        // Recover tool calls emitted as content instead of the native field —
4447        // the #1 weak-model failure (see `tool_recovery`). Mirror of the Ollama
4448        // loop: a local vLLM/llama.cpp server reports OpenAI-wire, so weak models
4449        // there drop content-emitted calls too. Recovered calls are native-shaped
4450        // and flow into the executor + is_hallucination path below.
4451        let recovered = if native_calls.map(|t| t.is_empty()).unwrap_or(true) {
4452            tool_recovery::recover_tool_calls(&oa_content)
4453        } else {
4454            tool_recovery::Recovery::default()
4455        };
4456        let tool_calls: Option<&Vec<serde_json::Value>> = match native_calls {
4457            Some(t) if !t.is_empty() => Some(t),
4458            _ if !recovered.calls.is_empty() => Some(&recovered.calls),
4459            _ => None,
4460        };
4461        let has_tools = tool_calls.map(|tc| !tc.is_empty()).unwrap_or(false);
4462        if debug && !recovered.calls.is_empty() {
4463            print_debug(
4464                &format!(
4465                    "recovered {} tool call(s) from content (non-native emission)",
4466                    recovered.calls.len()
4467                ),
4468                color,
4469            );
4470        }
4471
4472        if debug {
4473            let excerpt: String = oa_content.chars().take(80).collect();
4474            let tc_count = tool_calls.map(|tc| tc.len()).unwrap_or(0);
4475            let usage_str = match round_usage {
4476                Some(u) => format!("{} in / {} out", u.input_tokens, u.output_tokens),
4477                None => "no usage".into(),
4478            };
4479            print_debug(
4480                &format!(
4481                    "round {round}: tool_calls={tc_count} usage=[{usage_str}] content={excerpt:?}"
4482                ),
4483                color,
4484            );
4485        }
4486
4487        if !has_tools {
4488            // Format-hallucination tracker (mirror of the Ollama loop): content
4489            // that looked like a tool call but couldn't be recovered is counted.
4490            if recovered.tool_shaped {
4491                hallucination_count += 1;
4492                if debug {
4493                    print_debug(
4494                        "format-hallucination: tool call emitted as unrecoverable text",
4495                        color,
4496                    );
4497                }
4498            }
4499            let content = oa_content.clone();
4500            if content.is_empty() && debug {
4501                print_debug(
4502                    "empty content with no tool calls — model produced nothing",
4503                    color,
4504                );
4505            }
4506            // Narrate-then-stop rescue (mirror of the Ollama loop): non-empty
4507            // prose with no tool call. Nudge once and continue instead of ending
4508            // the turn — bounded by the configured narration_nudge_cap + the round budget.
4509            let nudge_classification =
4510                (!content.is_empty()).then(|| nudge_classifier.classify(&content));
4511            let workflow_classifier_text = workflow_classifier_text(&messages, &content);
4512            let workflow_hint = nudge_classification
4513                .as_ref()
4514                .filter(|classification| classification.is_plan_update())
4515                .and_then(|_| workflow_steerer.plan_update_hint(&workflow_classifier_text));
4516            let classifier_plan_direction = nudge_classification
4517                .as_ref()
4518                .filter(|classification| classification.is_plan_update())
4519                .and_then(|_| nudge_classifier.direction_for(crate::NudgeClass::PlanUpdate));
4520            let plan_nudge_hint =
4521                combine_nudge_hints(classifier_plan_direction, workflow_hint.as_deref());
4522            if !content.is_empty() && round + 1 < current_tool_round_limit {
4523                if let Some(nudge) = workflow_runtime.rediscovery_nudge(
4524                    nudge_classification.as_ref(),
4525                    &content,
4526                    step_ledger,
4527                ) {
4528                    if debug {
4529                        print_debug(
4530                            "workflow evidence rediscovery — nudging toward active repair",
4531                            color,
4532                        );
4533                    }
4534                    messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4535                    messages.push(serde_json::json!({
4536                        "role": "user",
4537                        "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, nudge)
4538                    }));
4539                    continue 'round_loop;
4540                }
4541            }
4542            if !content.is_empty()
4543                && pending_plan_nudges < PENDING_PLAN_NUDGE_CAP
4544                && round + 1 < current_tool_round_limit
4545            {
4546                let needs_plan_update = nudge_classification
4547                    .as_ref()
4548                    .is_some_and(|c| c.is_plan_update());
4549                if let Some(nudge) = pending_plan_completion_nudge(
4550                    step_ledger,
4551                    needs_plan_update,
4552                    plan_nudge_hint.as_deref(),
4553                ) {
4554                    if debug {
4555                        print_debug(
4556                            "active plan has unfinished steps — nudging before final answer",
4557                            color,
4558                        );
4559                    }
4560                    messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4561                    messages.push(serde_json::json!({
4562                        "role": "user",
4563                        "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, nudge)
4564                    }));
4565                    pending_plan_nudges += 1;
4566                    continue 'round_loop;
4567                }
4568            }
4569            if !content.is_empty()
4570                && stale_file_nudges < STALE_FILE_NUDGE_CAP
4571                && round + 1 < current_tool_round_limit
4572                && looks_like_unverified_stale_file_blocker(&content)
4573            {
4574                if debug {
4575                    print_debug(
4576                        "unverified stale-file blocker — nudging to check ground truth",
4577                        color,
4578                    );
4579                }
4580                messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4581                messages.push(serde_json::json!({
4582                    "role": "user",
4583                    "content": format!(
4584                        "{} {}",
4585                        compress::LOOP_GUIDANCE_PREFIX,
4586                        stale_file_ground_truth_nudge()
4587                    ),
4588                }));
4589                stale_file_nudges += 1;
4590                continue 'round_loop;
4591            }
4592            if !content.is_empty()
4593                && narration_nudges < narration_nudge_cap
4594                && round + 1 < current_tool_round_limit
4595                && nudge_classification
4596                    .as_ref()
4597                    .is_some_and(|c| c.is_pending_action())
4598            {
4599                if debug {
4600                    print_debug(
4601                        "narrated intent with no tool call — nudging to act and continuing",
4602                        color,
4603                    );
4604                }
4605                messages.push(serde_json::json!({ "role": "assistant", "content": content }));
4606                // First nudge: the (tunable) classifier direction. Later
4607                // nudges (cap > 1) escalate — name the active step, demand a
4608                // bare tool call (mirrors the Ollama loop).
4609                let direction = if narration_nudges == 0 {
4610                    nudge_classification
4611                        .as_ref()
4612                        .and_then(|classification| {
4613                            nudge_classifier.direction_for(classification.class)
4614                        })
4615                        .map(str::to_string)
4616                        .unwrap_or_else(narration_action_nudge)
4617                } else {
4618                    escalated_narration_action_nudge(
4619                        narration_nudges + 1,
4620                        narration_nudge_cap,
4621                        step_ledger,
4622                    )
4623                };
4624                messages.push(serde_json::json!({
4625                    "role": "user",
4626                    "content": format!("{} {}", compress::LOOP_GUIDANCE_PREFIX, direction),
4627                }));
4628                narration_nudges += 1;
4629                continue 'round_loop;
4630            }
4631            // Phase 20 §2.2: non-empty final content is usable output —
4632            // report the accepted prompt before returning.
4633            if !content.is_empty() {
4634                emit_accepted(
4635                    &mut on_round_usage,
4636                    round_usage,
4637                    truncation_suspect,
4638                    round_est_raw,
4639                );
4640            }
4641            // Acceptance forensics (mirrors the Ollama macro): record WHY
4642            // this no-tool reply ends the turn.
4643            let accepted_reason = if content.is_empty() {
4644                crate::TurnEndReason::Empty
4645            } else if nudge_classification
4646                .as_ref()
4647                .is_some_and(|c| c.is_pending_action())
4648            {
4649                if round + 1 >= current_tool_round_limit {
4650                    crate::TurnEndReason::NarrationFinalRound
4651                } else {
4652                    crate::TurnEndReason::NarrationCapExhausted
4653                }
4654            } else {
4655                crate::TurnEndReason::Completed
4656            };
4657            if debug && accepted_reason != crate::TurnEndReason::Completed {
4658                print_debug(
4659                    &format!("no-tool reply accepted as final answer ({accepted_reason:?})"),
4660                    color,
4661                );
4662            }
4663            if let Some(slot) = &mut end_reason {
4664                **slot = Some(accepted_reason);
4665            }
4666            let out = if content.is_empty() {
4667                "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
4668            } else {
4669                content
4670            };
4671            return Ok((out, false, accumulated_usage, hallucination_count));
4672        }
4673
4674        // Record the assistant turn (it carries the tool_calls), then run each call
4675        // and feed the result back keyed by its tool_call_id.
4676        // Phase 20 §2.2: tool calls are usable output (mirrors the Ollama path).
4677        emit_accepted(
4678            &mut on_round_usage,
4679            round_usage,
4680            truncation_suspect,
4681            round_est_raw,
4682        );
4683        // #857: re-send a CLEAN assistant turn — stripped content (no inline
4684        // <think>) and no prior-turn `reasoning_content` (the model must not be fed
4685        // its own CoT back). The `tool_calls` are preserved.
4686        let mut assistant_turn = message.clone();
4687        assistant_turn["content"] = serde_json::Value::String(oa_content.clone());
4688        if let Some(obj) = assistant_turn.as_object_mut() {
4689            obj.remove("reasoning_content");
4690        }
4691        messages.push(assistant_turn);
4692        let mut round_modified_workspace = false;
4693        let mut round_progress = false;
4694        for tc in tool_calls.unwrap() {
4695            let id = tc["id"].as_str().unwrap_or("");
4696            // Some API proxies (e.g. NVIDIA inference → Anthropic backend) wrap
4697            // Anthropic-native tool-use blocks inside the OpenAI `tool_calls`
4698            // array without converting the inner schema.  Fall back from the
4699            // OpenAI path (`function.name` / `function.arguments`) to the
4700            // Anthropic-native path (`name` / `input`) when the `function` key
4701            // is absent, so both wire formats route correctly.
4702            let anthropic_native = tc["function"].is_null();
4703            if anthropic_native && debug {
4704                let raw_name = tc["name"].as_str().unwrap_or("<missing>");
4705                print_debug(
4706                    &format!(
4707                        "tool call in Anthropic-native format inside tool_calls array \
4708                         (no `function` key) — name={raw_name:?}"
4709                    ),
4710                    color,
4711                );
4712            }
4713            let name = if anthropic_native {
4714                tc["name"].as_str().unwrap_or("unknown")
4715            } else {
4716                tc["function"]["name"].as_str().unwrap_or("unknown")
4717            };
4718            let args = if anthropic_native {
4719                tc["input"].clone()
4720            } else {
4721                match &tc["function"]["arguments"] {
4722                    serde_json::Value::String(s) => {
4723                        serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
4724                    }
4725                    v => v.clone(),
4726                }
4727            };
4728            if trace {
4729                print_trace(
4730                    &format!(
4731                        "raw tool_call element: {}",
4732                        serde_json::to_string(tc).unwrap_or_else(|_| "?".into())
4733                    ),
4734                    color,
4735                );
4736            }
4737            let mcp_handles = mcp.handles(name);
4738            if debug {
4739                print_debug(
4740                    &format!("dispatching tool name={name:?} mcp_handles={mcp_handles}"),
4741                    color,
4742                );
4743            }
4744            if is_hallucination(name, &args) {
4745                hallucination_count += 1;
4746            }
4747            // Step 27.3/#771: short-circuit selected exact repeats (mirrors the
4748            // Ollama path; Responses uses function_call_output). Counted as a
4749            // hallucination above first when applicable.
4750            if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
4751                if let Some(rec) = tool_events.as_deref_mut() {
4752                    rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
4753                }
4754                messages.push(serde_json::json!({
4755                    "role": "tool",
4756                    "tool_call_id": id,
4757                    "content": steer,
4758                }));
4759                continue;
4760            }
4761            // Organic save_note use resets the memory-nudge counter (mirrors
4762            // the Ollama path).
4763            if name == "save_note" && note_sink.is_some() {
4764                if let Some(n) = note_nudge.as_deref_mut() {
4765                    n.note_saved();
4766                }
4767            }
4768            // retry technique: snapshot the file's pre-write bytes before the
4769            // write tool runs, so the post-turn gate can revert exactly newt's writes.
4770            ledger_note_write(write_ledger, name, &args, workspace);
4771            let tool_t0 = std::time::Instant::now();
4772            // #727: intercept the read-only budget self-read (see the Ollama path).
4773            // num_ctx is not applicable on OpenAI-compatible endpoints (so the
4774            // ceiling is usually None → an honest "no ceiling configured"), but the
4775            // used-token figure is still reported.
4776            let result = if tools::is_context_remaining_call(name) {
4777                let report = budget::render_context_budget(
4778                    prompt_tracker.current(&messages, Some(&tools), cal, estimation),
4779                    num_ctx_input_ceiling(num_ctx, input_ceiling_pct),
4780                    num_ctx,
4781                    input_ceiling_pct as usize,
4782                    low_budget_pct,
4783                );
4784                display::print_tool_call("get_context_remaining", "", color);
4785                display::print_tool_output(&report, tool_output_lines, color);
4786                report
4787            } else {
4788                execute_tool_with_offload(
4789                    name,
4790                    &args,
4791                    workspace,
4792                    color,
4793                    tool_output_lines,
4794                    caveats,
4795                    mcp,
4796                    build_check_cmd.as_deref(),
4797                    // Reborrow + re-coerce: shortens the trait-object lifetime to
4798                    // this call (Option<&mut dyn _> is invariant, so the longer
4799                    // ChatCtx lifetime can't unify directly).
4800                    note_sink
4801                        .as_deref_mut()
4802                        .map(|s| &mut *s as &mut dyn NoteSink),
4803                    recall_source,
4804                    memory_source,
4805                    // #263 prompted grants — same reborrow pattern as note_sink.
4806                    permission_gate
4807                        .as_deref_mut()
4808                        .map(|g| &mut *g as &mut dyn PermissionGate),
4809                    // #307: the active preset's exec floor (the bypass ceiling).
4810                    exec_floor,
4811                    // PR4: the injected embedded-git capability (None for headless).
4812                    git_tool,
4813                    // #479: the injected crew/team orchestration (None for headless).
4814                    crew_runner,
4815                    scratchpad_store,
4816                    code_search,
4817                    experience_store,
4818                    step_ledger,
4819                    tool_offload,
4820                    spill_store,
4821                    persona_tools,
4822                )
4823                .await
4824            };
4825            if debug {
4826                let excerpt: String = result.chars().take(120).collect();
4827                print_debug(&format!("tool result: {excerpt:?}"), color);
4828            }
4829            // 17.6: record the call for the turn's events column (mirrors
4830            // the Ollama path) — digested args, duration as a display claim.
4831            // Step 27.3/#771: classify once; remember repeat-steered outcomes
4832            // (mirrors Ollama path).
4833            let ok = tools::tool_result_ok(&result);
4834            if ok && is_workspace_write_call(name) {
4835                round_modified_workspace = true;
4836            }
4837            if ok && meaningful_workflow_progress(name, &result) {
4838                round_progress = true;
4839            }
4840            repeat_calls.record(name, &args, ok, &result);
4841            if workflow_runtime.record_tool_result(&result) {
4842                round_progress = true;
4843            }
4844            if let Some(rec) = tool_events.as_deref_mut() {
4845                rec.push(crate::ToolEvent::from_call(
4846                    name,
4847                    &args,
4848                    ok,
4849                    u64::try_from(tool_t0.elapsed().as_millis()).ok(),
4850                ));
4851            }
4852            // #717: record any phantom/capability reach (alias / hallucination
4853            // / real-tool empty miss) for the alias-seam telemetry. #479 (G4)
4854            // composes the gated-off seam here, where `advertise_team` is known:
4855            // a `crew`/`compose_roster` reach with the surface OFF is a real name
4856            // (so `classify_phantom_reach` never flags it) but exactly the
4857            // delegation signal we want to mine for the common OFF default.
4858            if let Some(pr) = phantom_reaches.as_deref_mut() {
4859                if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
4860                    .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
4861                {
4862                    pr.push(crate::PhantomReach {
4863                        name_as_called: name.to_string(),
4864                        resolution,
4865                        active_context_features: Vec::new(),
4866                    });
4867                }
4868            }
4869            // #867 Part A: ledger verified paths (see the Ollama path).
4870            observed_paths.record(&result, &observed_resolver);
4871            messages.push(serde_json::json!({
4872                "role": "tool",
4873                "tool_call_id": id,
4874                // Step 26.3 (#584): see the Ollama path.
4875                "content": maybe_offload_tool_result(name, result, tool_offload, spill_store),
4876            }));
4877        }
4878        workflow_runtime.record_round_outcome(round_modified_workspace, round_progress);
4879    }
4880
4881    // Reached the round cap. Trim the message list and make ONE final
4882    // tools-disabled completion (matches the Ollama path).
4883    let trimmed = trim_for_summary(&messages, 2, 6);
4884    // Step 27.5: salvage progress + failed-call count (matches the Ollama path).
4885    let progress = cap_exit_progress(step_ledger, scratchpad_store);
4886    let (text, streamed, usage) = final_summary_openai(
4887        &client,
4888        &chat_url,
4889        model,
4890        api_key,
4891        trimmed,
4892        CapExit {
4893            max_tool_rounds,
4894            accumulated: accumulated_usage,
4895            wasted_calls: repeat_calls.total_failures(),
4896            progress,
4897            observed: observed_paths.into_vec(),
4898        },
4899    )
4900    .await?;
4901    // #867: same path-claim refutation as the Ollama cap exit.
4902    let text = claim_check::annotate_against_workspace(text, workspace);
4903    if let Some(slot) = &mut end_reason {
4904        **slot = Some(crate::TurnEndReason::RoundCap);
4905    }
4906    Ok((text, streamed, usage, hallucination_count))
4907}
4908
4909// ── OpenAI Responses API (`POST /v1/responses`) ────────────────────────────
4910//
4911// The newer OpenAI surface. Models like `gpt-5-codex` are served ONLY here and
4912// 404 on `/v1/chat/completions`. The request/response shapes differ (input vs
4913// messages, instructions vs system message, a flatter tool schema, `output[]`
4914// items vs `choices`, `input_tokens`/`output_tokens` usage), so this is a
4915// parallel — deliberately leaner — loop. Selected per backend via
4916// `api = "responses"` (surfaced to the loop as `NEWT_OPENAI_API`). Non-streaming
4917// in v1 (matching the chat path's UX); the chat path's budget / cw-400 recovery
4918// is intentionally not duplicated here yet (opt-in path) — tracked.
4919
4920/// `true` when the active OpenAI backend selected the Responses API
4921/// (`[backends].api = "responses"`, surfaced to the loop as `NEWT_OPENAI_API`).
4922fn responses_api_selected() -> bool {
4923    std::env::var("NEWT_OPENAI_API")
4924        .ok()
4925        .is_some_and(|v| v.eq_ignore_ascii_case("responses"))
4926}
4927
4928/// Split chat-style messages into the Responses API's `(instructions, input)`:
4929/// `system`/`developer` messages concatenate into top-level `instructions`;
4930/// `user`/`assistant` become `input` message items (plain string content). Any
4931/// item already shaped as a Responses item (carrying a `type` field, e.g.
4932/// `function_call` / `function_call_output`) passes through untouched.
4933fn build_responses_input(
4934    messages: &[serde_json::Value],
4935) -> (Option<String>, Vec<serde_json::Value>) {
4936    let mut instructions: Vec<String> = Vec::new();
4937    let mut input: Vec<serde_json::Value> = Vec::new();
4938    for m in messages {
4939        if m.get("type").is_some() {
4940            input.push(m.clone());
4941            continue;
4942        }
4943        let role = m["role"].as_str().unwrap_or("user");
4944        let content = m["content"].as_str().unwrap_or("");
4945        match role {
4946            "system" | "developer" => instructions.push(content.to_string()),
4947            _ => input.push(serde_json::json!({ "role": role, "content": content })),
4948        }
4949    }
4950    let ins = (!instructions.is_empty()).then(|| instructions.join("\n\n"));
4951    (ins, input)
4952}
4953
4954/// Translate the chat/completions tool array (`{type:function,
4955/// function:{name,…}}` elements, as returned by `merged_tool_definitions`) to
4956/// the Responses API's flatter `{type:function, name, description, parameters}`.
4957/// An already-flat (or unknown) element passes through.
4958fn tools_to_responses(tools: &serde_json::Value) -> Vec<serde_json::Value> {
4959    tools
4960        .as_array()
4961        .map(|arr| {
4962            arr.iter()
4963                .map(|t| {
4964                    let f = &t["function"];
4965                    if f.is_object() {
4966                        serde_json::json!({
4967                            "type": "function",
4968                            "name": f["name"],
4969                            "description": f["description"],
4970                            "parameters": f["parameters"],
4971                        })
4972                    } else {
4973                        t.clone()
4974                    }
4975                })
4976                .collect()
4977        })
4978        .unwrap_or_default()
4979}
4980
4981/// Extract `(assistant_text, function_call_items)` from a Responses reply's
4982/// `output[]`: text is the concatenation of `output_text` parts inside
4983/// `message` items; `function_call` items are returned verbatim (they carry
4984/// `call_id` / `name` / `arguments` and are echoed back into the next request).
4985/// Falls back to a flattened top-level `output_text` if the structured walk
4986/// found no text.
4987fn parse_responses_output(json: &serde_json::Value) -> (String, Vec<serde_json::Value>) {
4988    let mut text = String::new();
4989    let mut calls = Vec::new();
4990    if let Some(items) = json["output"].as_array() {
4991        for item in items {
4992            match item["type"].as_str() {
4993                Some("message") => {
4994                    if let Some(parts) = item["content"].as_array() {
4995                        for p in parts {
4996                            if let Some(t) = p["text"].as_str() {
4997                                text.push_str(t);
4998                            }
4999                        }
5000                    }
5001                }
5002                Some("function_call") => calls.push(item.clone()),
5003                _ => {}
5004            }
5005        }
5006    }
5007    if text.is_empty() {
5008        if let Some(t) = json["output_text"].as_str() {
5009            text.push_str(t);
5010        }
5011    }
5012    (text, calls)
5013}
5014
5015/// Responses API usage → `TokenUsage` (`input_tokens`/`output_tokens`, distinct
5016/// from chat/completions' `prompt_tokens`/`completion_tokens`).
5017fn responses_usage(v: &serde_json::Value) -> Option<crate::TokenUsage> {
5018    let input = v["input_tokens"].as_u64().map(|n| n as u32);
5019    let output = v["output_tokens"].as_u64().map(|n| n as u32);
5020    input.zip(output).map(|(i, o)| crate::TokenUsage {
5021        input_tokens: i,
5022        output_tokens: o,
5023    })
5024}
5025
5026/// The OpenAI **Responses API** agentic loop (`POST {endpoint}/v1/responses`).
5027/// Parallel to [`openai_chat_complete`] but over the Responses shapes, for
5028/// models served only there (`gpt-5-codex`). Non-streaming; selected via
5029/// `api = "responses"`. The chat path's budget / cw-400 recovery is not yet
5030/// mirrored here (opt-in path) — tracked as a follow-up.
5031pub async fn openai_responses_complete(
5032    ctx: ChatCtx<'_>,
5033    mcp: &mut dyn McpTools,
5034) -> anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)> {
5035    let ChatCtx {
5036        url,
5037        model,
5038        kind: _,
5039        api_key,
5040        messages: mem_messages,
5041        task: _,
5042        workspace,
5043        color,
5044        markdown: _,
5045        tool_offload,
5046        spill_store,
5047        compaction_store,
5048        scratchpad,
5049        scratchpad_store,
5050        code_search,
5051        experience_store,
5052        step_ledger,
5053        caveats,
5054        persona_tools,
5055        max_tool_rounds,
5056        workflow_grace_rounds: _,
5057        narration_nudge_cap: _,
5058        tool_output_lines,
5059        debug,
5060        trace,
5061        // #727: bound (not `_`) so get_context_remaining can report the budget;
5062        // on the Responses wire num_ctx is normally unset (cloud), so the report
5063        // is honestly ceiling-less.
5064        num_ctx,
5065        connect_timeout_secs,
5066        inference_timeout_secs,
5067        mid_loop_trim_threshold: _,
5068        mid_loop_trim_tokens: _,
5069        max_ok_input: _,
5070        build_check_cmd,
5071        safe_context: _,
5072        recover_cw_400: _,
5073        mut note_sink,
5074        mut note_nudge,
5075        recall_source,
5076        memory_source,
5077        summarizer: _,
5078        compress_state: _,
5079        mut tool_events,
5080        mut phantom_reaches,
5081        end_reason: _,
5082        mut permission_gate,
5083        on_round_usage: _,
5084        estimate_ratio: _,
5085        // #727: bound for the get_context_remaining used-token estimate.
5086        estimation,
5087        summary_input_cap_floor_chars: _,
5088        input_ceiling_pct,
5089        low_budget_pct,
5090        exec_floor,
5091        write_ledger,
5092        cancel,
5093        git_tool,
5094        crew_runner,
5095    } = ctx;
5096    // The OpenAI-Responses loop offloads tool output (spill_store) but does not
5097    // run the compressor, so it never stores compaction spans.
5098    let _ = compaction_store;
5099
5100    let client = reqwest::Client::builder()
5101        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs))
5102        .timeout(std::time::Duration::from_secs(inference_timeout_secs))
5103        .build()?;
5104    let responses_url = format!("{}/v1/responses", url.trim_end_matches('/'));
5105    let retry = tui_retry_policy();
5106    let advertise_save_note = note_sink.is_some();
5107    let advertise_recall = recall_source.is_some();
5108    let advertise_memory_fetch = memory_source.is_some();
5109    // Step 26.4 (#583): state tools only when the feature is on AND a store exists.
5110    let advertise_scratchpad = scratchpad_store.is_some() && scratchpad;
5111    // Step 26.5.5 (#582): the code_search tool when a searcher is present.
5112    let advertise_code_search = code_search.is_some();
5113    // Step 26.6a (#585): the experiential tools when a store is present.
5114    let advertise_experiential = experience_store.is_some();
5115    // Step 26.6b (#586): the scheduled plan tools when a ledger is present.
5116    let advertise_scheduled = step_ledger.is_some();
5117    let advertise_git = git_tool.is_some();
5118    let advertise_team = crew_runner.is_some();
5119
5120    let msgs_json: Vec<serde_json::Value> = mem_messages
5121        .iter()
5122        .map(|m| serde_json::json!({"role": m.role.as_str(), "content": m.content}))
5123        .collect();
5124    let (instructions, mut input) = build_responses_input(&msgs_json);
5125    let tools_chat = merged_tool_definitions(
5126        mcp,
5127        advertise_save_note,
5128        advertise_recall,
5129        advertise_memory_fetch,
5130        advertise_git,
5131        advertise_team,
5132        advertise_scratchpad,
5133        advertise_code_search,
5134        advertise_experiential,
5135        advertise_scheduled,
5136    );
5137    // FR-1 part 2 (#997): scope the advertised catalog to the active persona
5138    // (Responses wire). No-op when `persona_tools` is `None`.
5139    let tools_chat = filter_advertised_tools(tools_chat, persona_tools);
5140    let tools = tools_to_responses(&tools_chat);
5141
5142    let mut accumulated_usage: Option<crate::TokenUsage> = None;
5143    let mut hallucination_count: u32 = 0;
5144    // Step 27.3/#771: guard against exact-repeat tool loops this run.
5145    let mut repeat_calls = RepeatCallGuard::default();
5146    let mut tools_supported = true;
5147    let mut tools_unsupported_notified = false;
5148
5149    let build_body = |input: &[serde_json::Value], with_tools: bool| {
5150        let mut body = serde_json::json!({ "model": model, "input": input, "stream": false });
5151        if let Some(ins) = &instructions {
5152            body["instructions"] = serde_json::json!(ins);
5153        }
5154        if with_tools && !tools.is_empty() {
5155            body["tools"] = serde_json::json!(tools);
5156            body["tool_choice"] = serde_json::json!("auto");
5157        }
5158        body
5159    };
5160
5161    for round in 0..max_tool_rounds {
5162        if is_cancelled(cancel) {
5163            return Ok((String::new(), false, accumulated_usage, hallucination_count));
5164        }
5165        let body = build_body(&input, tools_supported);
5166        let dispatch = with_backoff_notify(
5167            &retry,
5168            || async {
5169                let mut req = client.post(&responses_url).json(&body);
5170                if let Some(key) = api_key {
5171                    req = req.bearer_auth(key);
5172                }
5173                let resp = req
5174                    .send()
5175                    .await
5176                    .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
5177                if !resp.status().is_success() {
5178                    let status = resp.status();
5179                    let text = resp.text().await.unwrap_or_default();
5180                    anyhow::bail!("inference endpoint {status}: {text}");
5181                }
5182                resp.json::<serde_json::Value>()
5183                    .await
5184                    .map_err(anyhow::Error::from)
5185            },
5186            |attempt, delay| print_retry_indicator(attempt, delay, color),
5187        )
5188        .await;
5189
5190        let json = match dispatch {
5191            Ok(j) => j,
5192            Err(e) => {
5193                if tools_supported && is_tools_unsupported_error(&e) {
5194                    tools_supported = false;
5195                    if !tools_unsupported_notified {
5196                        tools_unsupported_notified = true;
5197                        print_newt(
5198                            &format!(
5199                                "{model} does not support tools — tools disabled for this session"
5200                            ),
5201                            color,
5202                            false,
5203                        );
5204                    }
5205                    continue;
5206                }
5207                return Err(e);
5208            }
5209        };
5210
5211        let round_usage = responses_usage(&json["usage"]);
5212        accumulated_usage = merge_round_usage(accumulated_usage, round_usage);
5213        let (text, calls) = parse_responses_output(&json);
5214
5215        if debug {
5216            let excerpt: String = text.chars().take(80).collect();
5217            print_debug(
5218                &format!(
5219                    "responses round {round}: function_calls={} content={excerpt:?}",
5220                    calls.len()
5221                ),
5222                color,
5223            );
5224        }
5225
5226        if calls.is_empty() {
5227            let out = if text.is_empty() {
5228                "(model returned an empty response — try rephrasing, or check the model with `newt doctor`)".to_string()
5229            } else {
5230                text
5231            };
5232            return Ok((out, false, accumulated_usage, hallucination_count));
5233        }
5234
5235        // Echo the model's function_call items back into the running input,
5236        // then run each and append its function_call_output.
5237        for call in &calls {
5238            input.push(call.clone());
5239        }
5240        for call in &calls {
5241            let call_id = call["call_id"]
5242                .as_str()
5243                .or_else(|| call["id"].as_str())
5244                .unwrap_or("");
5245            let name = call["name"].as_str().unwrap_or("unknown");
5246            let args = match &call["arguments"] {
5247                serde_json::Value::String(s) => {
5248                    serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
5249                }
5250                v => v.clone(),
5251            };
5252            if trace {
5253                print_trace(
5254                    &format!(
5255                        "raw function_call: {}",
5256                        serde_json::to_string(call).unwrap_or_else(|_| "?".into())
5257                    ),
5258                    color,
5259                );
5260            }
5261            if is_hallucination(name, &args) {
5262                hallucination_count += 1;
5263            }
5264            // Step 27.3/#771: short-circuit selected exact repeats (Responses
5265            // shape: echo a function_call_output with the steer).
5266            // Counted as a hallucination above first when applicable.
5267            if let Some(steer) = repeat_calls.repeat_steer(name, &args) {
5268                if let Some(rec) = tool_events.as_deref_mut() {
5269                    rec.push(crate::ToolEvent::from_call(name, &args, false, Some(0)));
5270                }
5271                input.push(serde_json::json!({
5272                    "type": "function_call_output",
5273                    "call_id": call_id,
5274                    "output": steer,
5275                }));
5276                continue;
5277            }
5278            if name == "save_note" && note_sink.is_some() {
5279                if let Some(n) = note_nudge.as_deref_mut() {
5280                    n.note_saved();
5281                }
5282            }
5283            ledger_note_write(write_ledger, name, &args, workspace);
5284            let tool_t0 = std::time::Instant::now();
5285            // #727: intercept the read-only budget self-read (see the Ollama path).
5286            // The Responses loop has no PromptTracker, so `used` is the chars/4
5287            // estimate of the running `input` plus tool schemas; num_ctx is normally
5288            // unset here, so the report is honestly ceiling-less.
5289            let result = if tools::is_context_remaining_call(name) {
5290                let report = budget::render_context_budget(
5291                    estimate_request_tokens(&input, Some(&tools_chat), estimation),
5292                    num_ctx_input_ceiling(num_ctx, input_ceiling_pct),
5293                    num_ctx,
5294                    input_ceiling_pct as usize,
5295                    low_budget_pct,
5296                );
5297                display::print_tool_call("get_context_remaining", "", color);
5298                display::print_tool_output(&report, tool_output_lines, color);
5299                report
5300            } else {
5301                execute_tool_with_offload(
5302                    name,
5303                    &args,
5304                    workspace,
5305                    color,
5306                    tool_output_lines,
5307                    caveats,
5308                    mcp,
5309                    build_check_cmd.as_deref(),
5310                    note_sink
5311                        .as_deref_mut()
5312                        .map(|s| &mut *s as &mut dyn NoteSink),
5313                    recall_source,
5314                    memory_source,
5315                    permission_gate
5316                        .as_deref_mut()
5317                        .map(|g| &mut *g as &mut dyn PermissionGate),
5318                    exec_floor,
5319                    git_tool,
5320                    crew_runner,
5321                    scratchpad_store,
5322                    code_search,
5323                    experience_store,
5324                    step_ledger,
5325                    tool_offload,
5326                    spill_store,
5327                    persona_tools,
5328                )
5329                .await
5330            };
5331            if debug {
5332                let excerpt: String = result.chars().take(120).collect();
5333                print_debug(&format!("tool result: {excerpt:?}"), color);
5334            }
5335            // Step 27.3/#771: classify once; remember repeat-steered outcomes
5336            // (mirrors Ollama path).
5337            let ok = tools::tool_result_ok(&result);
5338            repeat_calls.record(name, &args, ok, &result);
5339            if let Some(rec) = tool_events.as_deref_mut() {
5340                rec.push(crate::ToolEvent::from_call(
5341                    name,
5342                    &args,
5343                    ok,
5344                    u64::try_from(tool_t0.elapsed().as_millis()).ok(),
5345                ));
5346            }
5347            // #717: record any phantom/capability reach (alias / hallucination
5348            // / real-tool empty miss) for the alias-seam telemetry. #479 (G4)
5349            // composes the gated-off seam here, where `advertise_team` is known:
5350            // a `crew`/`compose_roster` reach with the surface OFF is a real name
5351            // (so `classify_phantom_reach` never flags it) but exactly the
5352            // delegation signal we want to mine for the common OFF default.
5353            if let Some(pr) = phantom_reaches.as_deref_mut() {
5354                if let Some(resolution) = tools::classify_phantom_reach(name, &args, &result, ok)
5355                    .or_else(|| tools::classify_gated_off_reach(name, advertise_team))
5356                {
5357                    pr.push(crate::PhantomReach {
5358                        name_as_called: name.to_string(),
5359                        resolution,
5360                        active_context_features: Vec::new(),
5361                    });
5362                }
5363            }
5364            input.push(serde_json::json!({
5365                "type": "function_call_output",
5366                "call_id": call_id,
5367                // Step 26.3 (#584): see the Ollama path (Responses output shape).
5368                "output": maybe_offload_tool_result(name, result, tool_offload, spill_store),
5369            }));
5370        }
5371    }
5372
5373    // Round cap: one final tools-disabled call for a summary answer (mirrors
5374    // the chat path's final_summary, in the Responses shape).
5375    let body = build_body(&input, false);
5376    let mut req = client.post(&responses_url).json(&body);
5377    if let Some(key) = api_key {
5378        req = req.bearer_auth(key);
5379    }
5380    let resp = req.send().await?;
5381    if !resp.status().is_success() {
5382        let status = resp.status();
5383        let text = resp.text().await.unwrap_or_default();
5384        anyhow::bail!("inference endpoint {status}: {text}");
5385    }
5386    let json: serde_json::Value = resp.json().await?;
5387    accumulated_usage = merge_round_usage(accumulated_usage, responses_usage(&json["usage"]));
5388    let (text, _) = parse_responses_output(&json);
5389    Ok((text, false, accumulated_usage, hallucination_count))
5390}
5391
5392/// Whether the reasoning spinner is enabled: `NEWT_THINKING` (set by
5393/// `/thinking`) overrides `[tui] thinking`; default on.
5394fn thinking_stream_enabled() -> bool {
5395    match std::env::var("NEWT_THINKING").ok().as_deref() {
5396        Some("off") => return false,
5397        Some("on" | "stream") => return true,
5398        _ => {}
5399    }
5400    crate::Config::resolve()
5401        .ok()
5402        .and_then(|c| c.tui)
5403        .map(|t| t.thinking == crate::ThinkingMode::Stream)
5404        .unwrap_or(true)
5405}
5406
5407/// Spinner glyph frames (braille) for the thinking renderer.
5408const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5409
5410/// The ephemeral spinner line text. Pure for testing. A braille spinner that
5411/// advances every frame (so the line is visibly alive even while only the clock
5412/// moves), a stage `label` telling the user what's happening right now
5413/// (`thinking…`, `compressing context…`, …), and the elapsed seconds. The
5414/// `· N chars` tail is shown only once generation has produced output
5415/// (`chars > 0`).
5416fn format_spinner(frame: usize, secs: f32, label: &str, chars: usize) -> String {
5417    let braille = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()];
5418    if chars == 0 {
5419        format!("{braille} {label} {secs:.1}s")
5420    } else {
5421        format!("{braille} {label} {secs:.1}s · {chars} chars")
5422    }
5423}
5424
5425/// Cargo-style thinking renderer: a model's reasoning (#385 — normally
5426/// suppressed) streams as DIM scrolled lines that stay in scrollback, with one
5427/// **ephemeral** spinner line pinned at the bottom (`\r`-redrawn, cleared with
5428/// `\x1b[K` — one line, never a region; the plain-scroller carve-out), showing
5429/// elapsed + size. Erased the instant the answer begins. Constructed only when
5430/// the caller opts in (TTY); headless never builds it.
5431struct ThinkingSpinner {
5432    color: bool,
5433    frame: usize,
5434    start: std::time::Instant,
5435    chars: usize,
5436    line_buf: String,
5437    spinner_drawn: bool,
5438}
5439
5440impl ThinkingSpinner {
5441    fn new(color: bool) -> Self {
5442        Self {
5443            color,
5444            frame: 0,
5445            start: std::time::Instant::now(),
5446            chars: 0,
5447            line_buf: String::new(),
5448            spinner_drawn: false,
5449        }
5450    }
5451
5452    /// Feed a reasoning chunk: flush completed lines as dim scrollback, then
5453    /// redraw the bottom spinner.
5454    fn reasoning(&mut self, chunk: &str) {
5455        self.chars += chunk.chars().count();
5456        self.line_buf.push_str(chunk);
5457        while let Some(nl) = self.line_buf.find('\n') {
5458            let line: String = self.line_buf.drain(..=nl).collect();
5459            self.print_dim_line(line.trim_end_matches(['\n', '\r']));
5460        }
5461        self.redraw_spinner();
5462    }
5463
5464    fn print_dim_line(&mut self, line: &str) {
5465        self.erase_spinner();
5466        if line.trim().is_empty() {
5467            return;
5468        }
5469        let mut out = io::stdout();
5470        if self.color {
5471            let _ = execute!(
5472                out,
5473                SetForegroundColor(CtColor::DarkGrey),
5474                Print("  "),
5475                Print(line),
5476                ResetColor,
5477                Print("\n"),
5478            );
5479        } else {
5480            let _ = writeln!(out, "  {line}");
5481        }
5482    }
5483
5484    fn redraw_spinner(&mut self) {
5485        self.frame = self.frame.wrapping_add(1);
5486        let line = format_spinner(
5487            self.frame,
5488            self.start.elapsed().as_secs_f32(),
5489            "thinking…",
5490            self.chars,
5491        );
5492        // The spinner is redrawn in place with `\r` and never scrolls, so it
5493        // must not exceed the terminal width — a wrapped spinner leaves stale
5494        // rows behind. Truncate to width with a faded `…` tail.
5495        let fitted = display::fit_line(&line, display::term_cols());
5496        let mut out = io::stdout();
5497        if self.color {
5498            let _ = execute!(
5499                out,
5500                Print("\r\x1b[K"),
5501                SetForegroundColor(CtColor::DarkGrey),
5502                Print(&fitted.head),
5503                SetForegroundColor(display::FADE_CT),
5504                Print(&fitted.fade),
5505                Print(fitted.ellipsis),
5506                ResetColor,
5507            );
5508        } else {
5509            let _ = write!(out, "\r{}{}{}", fitted.head, fitted.fade, fitted.ellipsis);
5510        }
5511        let _ = out.flush();
5512        self.spinner_drawn = true;
5513    }
5514
5515    fn erase_spinner(&mut self) {
5516        if self.spinner_drawn {
5517            let _ = write!(io::stdout(), "\r\x1b[K");
5518            let _ = io::stdout().flush();
5519            self.spinner_drawn = false;
5520        }
5521    }
5522
5523    /// The answer is starting (or the stream ended): flush trailing reasoning
5524    /// and erase the spinner so output flows on cleanly. Idempotent.
5525    fn finish(&mut self) {
5526        if !self.line_buf.is_empty() {
5527            let tail = std::mem::take(&mut self.line_buf);
5528            self.print_dim_line(tail.trim_end_matches(['\n', '\r']));
5529        }
5530        self.erase_spinner();
5531    }
5532}
5533
5534/// Stream an Ollama NDJSON response, printing tokens as they arrive.
5535/// Returns `(accumulated_text, token_usage)`.
5536/// Token usage is extracted from the final chunk (`done: true`).
5537/// `show_thinking` opts into the cargo-style reasoning spinner (TTY only).
5538async fn stream_response(
5539    resp: reqwest::Response,
5540    color: bool,
5541    show_thinking: bool,
5542    leading_reasoning: bool,
5543    cancel: Option<&std::sync::atomic::AtomicBool>,
5544    markdown: bool,
5545) -> anyhow::Result<(String, Option<crate::TokenUsage>)> {
5546    let mut spinner = show_thinking.then(|| ThinkingSpinner::new(color));
5547    let mut full = String::new();
5548    let mut started = false;
5549    let mut usage: Option<crate::TokenUsage> = None;
5550    // Step 25.3 (#568): when markdown is active, route the *visible* token stream
5551    // through the block-aware writer (inline lines render per completed line;
5552    // fences/tables hold until they close). The accumulated `full` stays RAW —
5553    // it is persisted and re-sent to the model, so it must carry no ANSI. The
5554    // caller gates `markdown` on `color`, so the writer renders with `color: true`.
5555    let cols = display::term_cols();
5556    let mut md =
5557        markdown.then(|| MarkdownStreamWriter::new(io::stdout(), RenderOpts { color: true, cols }));
5558    // #385: suppress inline <think>…</think> reasoning from the live stream + the
5559    // accumulated reply, even when a tag is split across token boundaries.
5560    // #528: models that emit a lone leading `</think>` (no opener) start the
5561    // filter *inside* the reasoning block so the closer + reasoning don't leak.
5562    let mut think = if leading_reasoning {
5563        crate::reasoning::ThinkFilter::with_leading_reasoning()
5564    } else {
5565        crate::reasoning::ThinkFilter::new()
5566    };
5567
5568    let mut resp = resp;
5569    // Race each chunk read against the interrupt flag so Esc stops the token
5570    // stream promptly; on interrupt, stop reading and return what we have.
5571    while let Some(chunk) = match cancellable(cancel, resp.chunk()).await {
5572        Some(c) => c?,
5573        None => None,
5574    } {
5575        let text = String::from_utf8_lossy(&chunk);
5576        for line in text.lines() {
5577            if line.is_empty() {
5578                continue;
5579            }
5580            let Ok(json) = serde_json::from_str::<serde_json::Value>(line) else {
5581                continue;
5582            };
5583            let raw = json["message"]["content"].as_str().unwrap_or("");
5584            let (token, reasoning) = think.feed_split(raw);
5585            // Surface reasoning live (cargo-style) — both the inline `<think>`
5586            // span the filter just split out AND any separate `thinking` field.
5587            if let Some(sp) = spinner.as_mut() {
5588                if !reasoning.is_empty() {
5589                    sp.reasoning(&reasoning);
5590                }
5591                if let Some(t) = json["message"]["thinking"].as_str() {
5592                    if !t.is_empty() {
5593                        sp.reasoning(t);
5594                    }
5595                }
5596            }
5597            let token = token.as_str();
5598            if !token.is_empty() {
5599                if !started {
5600                    // The answer is starting — tear the spinner down first.
5601                    if let Some(sp) = spinner.as_mut() {
5602                        sp.finish();
5603                    }
5604                    if color {
5605                        execute!(
5606                            io::stdout(),
5607                            SetForegroundColor(NEWT_ORANGE_CT),
5608                            Print("▸  "),
5609                            ResetColor,
5610                        )
5611                        .ok();
5612                    } else {
5613                        print!("▸  ");
5614                    }
5615                    started = true;
5616                }
5617                if let Some(w) = md.as_mut() {
5618                    w.push(token).ok();
5619                } else {
5620                    print!("{token}");
5621                    io::stdout().flush().ok();
5622                }
5623                full.push_str(token);
5624            }
5625            if json["done"].as_bool().unwrap_or(false) {
5626                // Extract token counts from the final Ollama chunk.
5627                let input = json["prompt_eval_count"].as_u64().map(|n| n as u32);
5628                let output = json["eval_count"].as_u64().map(|n| n as u32);
5629                usage = input.zip(output).map(|(i, o)| crate::TokenUsage {
5630                    input_tokens: i,
5631                    output_tokens: o,
5632                });
5633                break;
5634            }
5635        }
5636    }
5637    // #385: flush any clean tail the filter held back (a trailing run that turned out
5638    // not to be the start of a `<think>` tag).
5639    let tail = think.finish();
5640    if !tail.is_empty() {
5641        if !started {
5642            if let Some(sp) = spinner.as_mut() {
5643                sp.finish();
5644            }
5645            print!("▸  ");
5646            started = true;
5647        }
5648        if let Some(w) = md.as_mut() {
5649            w.push(&tail).ok();
5650        } else {
5651            print!("{tail}");
5652            io::stdout().flush().ok();
5653        }
5654        full.push_str(&tail);
5655    }
5656    // All-reasoning response (no clean content): tear the spinner down anyway so
5657    // the terminal isn't left mid-spinner.
5658    if let Some(sp) = spinner.as_mut() {
5659        sp.finish();
5660    }
5661    // The markdown writer newline-terminates each line it emits, so it owns the
5662    // trailing newline; only the raw path needs the closing `println!`.
5663    if let Some(w) = md.as_mut() {
5664        w.finish().ok();
5665    }
5666    if started && md.is_none() {
5667        println!();
5668    }
5669    Ok((full, usage))
5670}
5671
5672#[cfg(test)]
5673mod repeat_call_guard_tests {
5674    use super::*;
5675
5676    #[test]
5677    fn short_circuits_exact_repeat_and_escalates() {
5678        let mut g = RepeatCallGuard::default();
5679        let args = serde_json::json!({"command": "mkdir x"});
5680        // First sight of the call → let it run (no steer).
5681        assert!(g.repeat_steer("run_command", &args).is_none());
5682        // After a failure, an exact repeat is steered, quoting the prior error.
5683        g.record("run_command", &args, false, "error: shell unavailable");
5684        let s = g.repeat_steer("run_command", &args).expect("repeat steers");
5685        assert!(s.contains("already called"), "{s}");
5686        assert!(s.contains("error: shell unavailable"), "{s}");
5687        assert!(
5688            !s.contains("stop using"),
5689            "one failure → no escalation yet: {s}"
5690        );
5691        // A second (distinct-args) failure of the same tool crosses ESCALATE_AFTER.
5692        g.record(
5693            "run_command",
5694            &serde_json::json!({"command": "ls"}),
5695            false,
5696            "error: denied",
5697        );
5698        let s2 = g.repeat_steer("run_command", &args).expect("still steers");
5699        assert!(s2.contains("stop using"), "escalates: {s2}");
5700    }
5701
5702    #[test]
5703    fn ignores_successes_and_distinct_calls() {
5704        let mut g = RepeatCallGuard::default();
5705        let a = serde_json::json!({"path": "f.rs"});
5706        g.record("read_file", &a, true, "file contents"); // success → not remembered
5707        assert!(g.repeat_steer("read_file", &a).is_none());
5708        // A failure under different args does not short-circuit a distinct call.
5709        let b = serde_json::json!({"path": "g.rs"});
5710        g.record("read_file", &b, false, "error reading g.rs");
5711        assert!(
5712            g.repeat_steer("read_file", &a).is_none(),
5713            "distinct args still run"
5714        );
5715        assert!(g.repeat_steer("read_file", &b).is_some());
5716    }
5717
5718    #[test]
5719    fn steers_no_result_repeats_on_second_issuance() {
5720        // #718: a success-shaped no-result that the model re-issues byte-for-byte
5721        // is steered on its 2nd call — distinct from a hard failure (no escalation),
5722        // distinct from a genuine success (which is never steered).
5723        let mut g = RepeatCallGuard::default();
5724
5725        // recall "no matches" — first sight runs; record it; the identical 2nd
5726        // issuance is steered before re-execution.
5727        let q = serde_json::json!({"query": "newt-tui PyO3 bindings"});
5728        assert!(
5729            g.repeat_steer("recall", &q).is_none(),
5730            "first recall must run"
5731        );
5732        g.record(
5733            "recall",
5734            &q,
5735            true,
5736            "no matches in past conversations for \"newt-tui PyO3 bindings\" — try different keywords.",
5737        );
5738        let s = g
5739            .repeat_steer("recall", &q)
5740            .expect("2nd identical recall steers");
5741        assert!(s.contains("no matches"), "{s}");
5742        assert!(
5743            s.contains("resume_context"),
5744            "recall steer points at resume_context: {s}"
5745        );
5746        assert!(
5747            !s.contains("stop using"),
5748            "a no-result is not a hard failure — no escalation: {s}"
5749        );
5750
5751        // state_get "no such key" — same: 2nd identical probe is steered.
5752        let k = serde_json::json!({"key": "current_task"});
5753        assert!(g.repeat_steer("state_get", &k).is_none());
5754        g.record("state_get", &k, true, "no such key: current_task");
5755        assert!(
5756            g.repeat_steer("state_get", &k).is_some(),
5757            "2nd identical state_get steers"
5758        );
5759
5760        // plan_get empty ledger — same: the second identical read is steered
5761        // toward creating the missing plan instead of polling the empty ledger.
5762        let empty_plan_args = serde_json::json!({});
5763        assert!(g.repeat_steer("plan_get", &empty_plan_args).is_none());
5764        g.record(
5765            "plan_get",
5766            &empty_plan_args,
5767            true,
5768            "no active plan — if this is multi-step work, call update_plan next",
5769        );
5770        let plan_steer = g
5771            .repeat_steer("plan_get", &empty_plan_args)
5772            .expect("2nd identical empty plan_get steers");
5773        assert!(plan_steer.contains("update_plan"), "{plan_steer}");
5774
5775        // A genuine success with content is still NEVER steered on repeat.
5776        let f = serde_json::json!({"path": "f.rs"});
5777        g.record("read_file", &f, true, "file contents");
5778        assert!(g.repeat_steer("read_file", &f).is_none());
5779
5780        // A no-result under DIFFERENT args is a distinct call — let it run.
5781        let q2 = serde_json::json!({"query": "something else entirely"});
5782        assert!(
5783            g.repeat_steer("recall", &q2).is_none(),
5784            "distinct recall args still run"
5785        );
5786    }
5787
5788    #[test]
5789    fn steers_duplicate_successful_web_fetch() {
5790        let mut g = RepeatCallGuard::default();
5791        let issue = serde_json::json!({
5792            "url": "https://github.com/Gilamonster-Foundation/newt-agent/issues/771"
5793        });
5794
5795        assert!(
5796            g.repeat_steer("web_fetch", &issue).is_none(),
5797            "first fetch must run"
5798        );
5799        g.record("web_fetch", &issue, true, "# Issue\n\nbody");
5800        let steer = g
5801            .repeat_steer("web_fetch", &issue)
5802            .expect("2nd identical successful fetch steers");
5803        assert!(steer.contains("already observed"), "{steer}");
5804        assert!(steer.contains("`web_fetch`"), "{steer}");
5805        assert!(
5806            steer.contains("https://github.com/Gilamonster-Foundation/newt-agent/issues/771"),
5807            "{steer}"
5808        );
5809        assert!(
5810            g.repeat_steer(
5811                "web_fetch",
5812                &serde_json::json!({"url": "https://github.com/hartsock/scrybe"})
5813            )
5814            .is_none(),
5815            "distinct URLs still run"
5816        );
5817
5818        let file = serde_json::json!({"path": "src/lib.rs"});
5819        g.record("read_file", &file, true, "file contents");
5820        assert!(
5821            g.repeat_steer("read_file", &file).is_none(),
5822            "ordinary successful reads are still not steered"
5823        );
5824    }
5825
5826    #[test]
5827    fn steers_duplicate_successful_read_only_run_command() {
5828        let mut g = RepeatCallGuard::default();
5829        let args = serde_json::json!({
5830            "command": "grep -n 'help_lines' /Users/shawnhartsock/workspaces/newt-agent/newt-tui/src/lib.rs"
5831        });
5832
5833        assert!(
5834            g.repeat_steer("run_command", &args).is_none(),
5835            "first grep should run"
5836        );
5837        g.record(
5838            "run_command",
5839            &args,
5840            true,
5841            "9439:fn help_lines() -> &'static [&'static str] {",
5842        );
5843
5844        let steer = g
5845            .repeat_steer("run_command", &args)
5846            .expect("second identical grep should steer");
5847        assert!(steer.contains("already observed"), "{steer}");
5848        assert!(steer.contains("read-only shell probe"), "{steer}");
5849        assert!(steer.contains("`run_command`"), "{steer}");
5850        assert!(steer.contains("grep -n"), "{steer}");
5851        assert!(steer.contains("Do NOT repeat"), "{steer}");
5852    }
5853
5854    #[test]
5855    fn does_not_steer_successful_write_capable_run_command() {
5856        let mut g = RepeatCallGuard::default();
5857        let args = serde_json::json!({"command": "cargo test -p newt-tui"});
5858
5859        g.record("run_command", &args, true, "test result: ok");
5860
5861        assert!(
5862            g.repeat_steer("run_command", &args).is_none(),
5863            "successful build/test commands are still repeatable"
5864        );
5865    }
5866
5867    #[test]
5868    fn classifier_leaves_ordinary_successes_repeatable() {
5869        let file = serde_json::json!({"path": "src/lib.rs"});
5870        assert_eq!(
5871            RepeatCallGuard::classify_repeat_memo("read_file", &file, true, "file contents"),
5872            None
5873        );
5874
5875        let tests = serde_json::json!({"command": "cargo test -p newt-core"});
5876        assert_eq!(
5877            RepeatCallGuard::classify_repeat_memo("run_command", &tests, true, "test result: ok"),
5878            None
5879        );
5880
5881        let mut g = RepeatCallGuard::default();
5882        g.record("read_file", &file, true, "file contents");
5883        g.record("run_command", &tests, true, "test result: ok");
5884        assert!(
5885            g.repeat_memos.is_empty(),
5886            "ordinary successful calls must stay repeatable"
5887        );
5888    }
5889
5890    #[test]
5891    fn workflow_error_fingerprint_captures_cargo_location() {
5892        let output = r#"
5893error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5894   --> newt-tui/src/help_sections.rs:523:22
5895    |
5896523 |         lines: SECTION_PROMPT_TOKENS,
5897    |                ^^^^^^^^^^^^^^^^^^^^^ help: a static with a similar name exists: `SECTION_PROMPT`
5898"#;
5899
5900        let fp = build_error_fingerprint(output).expect("cargo error should fingerprint");
5901
5902        assert!(fp.contains("newt-tui/src/help_sections.rs:523:22"), "{fp}");
5903        assert!(fp.contains("error[E0425]"), "{fp}");
5904        assert!(fp.contains("SECTION_PROMPT_TOKENS"), "{fp}");
5905    }
5906
5907    #[test]
5908    fn workflow_runtime_nudges_after_error_without_writes() {
5909        let output = r#"
5910error[E0425]: cannot find value `SECTION_PROMPT_TOKENS` in this scope
5911   --> newt-tui/src/help_sections.rs:523:22
5912"#;
5913        let mut state = WorkflowRuntimeState::default();
5914
5915        state.record_tool_result(output);
5916        state.record_round_outcome(false, false);
5917
5918        let nudge = state
5919            .round_start_nudge(None)
5920            .expect("read-only round after evidence should lock the active repair");
5921        assert!(nudge.contains("<workflow_state>"), "{nudge}");
5922        assert!(
5923            nudge.contains("newt-tui/src/help_sections.rs:523:22"),
5924            "{nudge}"
5925        );
5926        assert!(nudge.contains("next_allowed_actions"), "{nudge}");
5927        assert!(nudge.contains("disallowed_actions"), "{nudge}");
5928
5929        let classification = crate::NudgeClassification {
5930            class: crate::NudgeClass::PlanUpdate,
5931            score: 1.0,
5932        };
5933        let rediscovery = state
5934            .rediscovery_nudge(
5935                Some(&classification),
5936                "Summary of Findings\nRoot Cause: the build failure is still present.",
5937                None,
5938            )
5939            .expect("classified summary should be steered toward action");
5940        assert!(
5941            rediscovery.contains("Do not restate findings"),
5942            "{rediscovery}"
5943        );
5944        assert!(
5945            rediscovery.contains("newt-tui/src/help_sections.rs:523:22"),
5946            "{rediscovery}"
5947        );
5948    }
5949
5950    #[test]
5951    fn workflow_runtime_tracks_failed_edit_as_unresolved_evidence() {
5952        let output = "error: old_string not found in newt-tui/src/help_sections.rs";
5953        let mut state = WorkflowRuntimeState::default();
5954
5955        state.record_tool_result(output);
5956        state.record_round_outcome(false, false);
5957
5958        let nudge = state
5959            .round_start_nudge(None)
5960            .expect("failed edit should remain unresolved repair evidence");
5961        assert!(nudge.contains("old_string not found"), "{nudge}");
5962
5963        let grace = state
5964            .cap_grace_nudge(None, 25, 5)
5965            .expect("cap after failed edit/read-only recovery should grant an action round");
5966        assert!(
5967            grace.contains("configured_workflow_grace_rounds = 5"),
5968            "{grace}"
5969        );
5970        assert!(
5971            grace.contains("call edit_file or write_file now"),
5972            "{grace}"
5973        );
5974        assert!(
5975            state.cap_grace_nudge(None, 25, 0).is_none(),
5976            "configured zero grace disables soft cap extension"
5977        );
5978
5979        state.record_round_outcome(true, true);
5980        let verify = state
5981            .cap_grace_nudge(None, 25, 3)
5982            .expect("a successful edit at the cap should get a verification window");
5983        assert!(verify.contains("focused verification"), "{verify}");
5984        assert!(
5985            verify.contains("configured_workflow_grace_rounds = 3"),
5986            "{verify}"
5987        );
5988    }
5989
5990    #[test]
5991    fn workflow_runtime_grants_configured_grace_for_recent_plan_progress() {
5992        let ledger = SessionStepLedger::default();
5993        ledger.set_plan(&["finish round-cap grace".to_string(), "verify".to_string()]);
5994        let mut state = WorkflowRuntimeState::default();
5995
5996        state.record_round_outcome(false, true);
5997
5998        let nudge = state
5999            .cap_grace_nudge(Some(&ledger), 2, 4)
6000            .expect("recent active-plan progress should activate configured grace");
6001        assert!(
6002            nudge.contains("configured_workflow_grace_rounds = 4"),
6003            "{nudge}"
6004        );
6005        assert!(nudge.contains("finish round-cap grace"), "{nudge}");
6006        assert!(
6007            state.cap_grace_nudge(Some(&ledger), 2, 0).is_none(),
6008            "zero configured grace keeps the cap hard"
6009        );
6010    }
6011
6012    /// #<issue>: a diagnostic workflow (e.g. `diagnose_failure.toml`,
6013    /// `progress_horizon_rounds = 6`) legitimately spends more read-only
6014    /// rounds between plan checkpoints than a routine edit does. Without a
6015    /// horizon override, 4 rounds since the last checkpoint already exceeds
6016    /// the shared default (`WORKFLOW_RECENT_PROGRESS_ROUNDS = 3`) and grace
6017    /// does NOT activate — RED on the pre-fix behavior. Setting the override
6018    /// widens the window so the same 4-rounds-stale state still counts as
6019    /// "recent" — GREEN.
6020    #[test]
6021    fn progress_horizon_override_widens_the_recent_progress_window() {
6022        let ledger = SessionStepLedger::default();
6023        ledger.set_plan(&["diagnose the failure".to_string(), "fix it".to_string()]);
6024
6025        let mut default_horizon = WorkflowRuntimeState::default();
6026        default_horizon.record_round_outcome(false, true); // a checkpoint...
6027        for _ in 0..4 {
6028            default_horizon.record_round_outcome(false, false); // ...then 4 idle rounds
6029        }
6030        assert!(
6031            default_horizon
6032                .cap_grace_nudge(Some(&ledger), 2, 4)
6033                .is_none(),
6034            "4 rounds since the last checkpoint exceeds the default 3-round horizon"
6035        );
6036
6037        let mut widened = WorkflowRuntimeState::default();
6038        widened.set_progress_horizon(Some(6));
6039        widened.record_round_outcome(false, true);
6040        for _ in 0..4 {
6041            widened.record_round_outcome(false, false);
6042        }
6043        assert!(
6044            widened.cap_grace_nudge(Some(&ledger), 2, 4).is_some(),
6045            "a widened 6-round horizon still treats 4-rounds-stale as recent progress"
6046        );
6047    }
6048
6049    #[test]
6050    fn workspace_write_classifier_is_narrow() {
6051        assert!(is_workspace_write_call("edit_file"));
6052        assert!(is_workspace_write_call("write_file"));
6053        assert!(!is_workspace_write_call("run_command"));
6054        assert!(!is_workspace_write_call("read_file"));
6055    }
6056
6057    #[test]
6058    fn no_result_reason_classifies_and_routes() {
6059        // recall / state_get no-result prefixes classify…
6060        assert!(RepeatCallGuard::no_result_reason(
6061            "recall",
6062            "no matches in past conversations for \"x\" — try different keywords."
6063        )
6064        .is_some_and(|r| r.contains("no matches") && r.contains("resume_context")));
6065        assert!(
6066            RepeatCallGuard::no_result_reason("state_get", "no such key: current_task")
6067                .is_some_and(|r| r.contains("not set"))
6068        );
6069        assert!(
6070            RepeatCallGuard::no_result_reason("plan_get", "no active plan — call update_plan")
6071                .is_some_and(|r| r.contains("update_plan"))
6072        );
6073        // …a real success with content does not.
6074        assert!(
6075            RepeatCallGuard::no_result_reason("recall", "3 match(es) in past conversations")
6076                .is_none()
6077        );
6078        assert!(RepeatCallGuard::no_result_reason("read_file", "file contents").is_none());
6079
6080        // A recall ERROR (ok=false) goes through the FAILURE path, not no-result
6081        // classification: it lands in repeat_memos as escalation-eligible.
6082        let mut g = RepeatCallGuard::default();
6083        let q = serde_json::json!({"query": "x"});
6084        g.record("recall", &q, false, "error: index unavailable");
6085        assert!(matches!(
6086            g.repeat_memos.get(&RepeatCallGuard::key("recall", &q)),
6087            Some(RepeatMemo::Failure { first_line }) if first_line == "error: index unavailable"
6088        ));
6089    }
6090
6091    #[test]
6092    fn first_line_caps_and_takes_first() {
6093        assert_eq!(first_line("one\ntwo\nthree"), "one");
6094        assert_eq!(first_line(""), "");
6095        assert_eq!(first_line(&"x".repeat(500)).chars().count(), 200);
6096    }
6097}
6098
6099#[cfg(test)]
6100mod cap_exit_unit_tests {
6101    use super::*;
6102
6103    #[test]
6104    fn cap_exit_nudge_names_the_limit_and_folds_in_progress() {
6105        let nudge = cap_exit_nudge(5, None, &[]);
6106        assert!(nudge.contains("5 rounds"), "got: {nudge}");
6107        assert!(nudge.contains("Do NOT call any more tools"));
6108        // #867: the grounding constraint — the trim just deleted the evidence,
6109        // so the nudge must forbid reconstructing paths from memory.
6110        assert!(
6111            nudge.contains("Cite only file paths that appear verbatim"),
6112            "got: {nudge}"
6113        );
6114        assert!(nudge.contains("say so plainly"), "got: {nudge}");
6115        assert!(
6116            !nudge.contains("progress so far"),
6117            "no block when None: {nudge}"
6118        );
6119        assert!(
6120            !nudge.contains("actually observed"),
6121            "no manifest block when the ledger is empty: {nudge}"
6122        );
6123        // Step 27.5: the <plan>/<state> progress is folded into the nudge.
6124        let with = cap_exit_nudge(5, Some("<plan>1. [x] foo</plan>"), &[]);
6125        assert!(with.contains("Your progress so far"), "got: {with}");
6126        assert!(with.contains("<plan>1. [x] foo</plan>"), "got: {with}");
6127    }
6128
6129    /// #867 Part A: the observed-paths manifest survives the trim and is
6130    /// handed to the model as the citable ground truth.
6131    #[test]
6132    fn cap_exit_nudge_folds_in_the_observed_paths_manifest() {
6133        let observed = vec![
6134            "newt-tui/src/lib.rs".to_string(),
6135            "newt-core/src/agentic/mod.rs".to_string(),
6136        ];
6137        let nudge = cap_exit_nudge(5, Some("<state>k=v</state>"), &observed);
6138        assert!(
6139            nudge.contains("File paths actually observed in tool results"),
6140            "got: {nudge}"
6141        );
6142        assert!(nudge.contains("- newt-tui/src/lib.rs"), "got: {nudge}");
6143        assert!(
6144            nudge.contains("- newt-core/src/agentic/mod.rs"),
6145            "got: {nudge}"
6146        );
6147        // Manifest precedes the progress block; both survive together.
6148        let manifest_at = nudge.find("actually observed").unwrap();
6149        let progress_at = nudge.find("Your progress so far").unwrap();
6150        assert!(manifest_at < progress_at, "got: {nudge}");
6151    }
6152
6153    #[test]
6154    fn cap_exit_progress_renders_plan_and_state_or_none() {
6155        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6156        use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
6157        let ledger = SessionStepLedger::default();
6158        let pad = SessionScratchpadStore::default();
6159        // Both empty → nothing to salvage.
6160        assert!(cap_exit_progress(Some(&ledger), Some(&pad)).is_none());
6161        assert!(cap_exit_progress(None, None).is_none());
6162        // Populated → a combined block naming both.
6163        ledger.set_plan(&["build it".to_string(), "test it".to_string()]);
6164        pad.set("cwd", "/work".to_string());
6165        let p = cap_exit_progress(
6166            Some(&ledger as &dyn StepLedger),
6167            Some(&pad as &dyn ScratchpadStore),
6168        )
6169        .expect("non-empty progress");
6170        assert!(p.contains("build it"), "{p}");
6171        assert!(p.contains("cwd"), "{p}");
6172    }
6173
6174    #[test]
6175    fn spinner_line_formats_and_frame_wraps() {
6176        // Braille spinner + stage label + clock; the chars tail shows once
6177        // generation has produced output.
6178        assert_eq!(
6179            format_spinner(0, 1.23, "thinking…", 340),
6180            "⠋ thinking… 1.2s · 340 chars"
6181        );
6182        // A different stage label, and chars == 0 drops the `· N chars` tail.
6183        assert_eq!(
6184            format_spinner(1, 0.5, "compressing context…", 0),
6185            "⠙ compressing context… 0.5s"
6186        );
6187        // Frame index wraps over the braille glyph set.
6188        assert!(
6189            format_spinner(SPINNER_FRAMES.len(), 0.0, "thinking…", 0).contains(SPINNER_FRAMES[0])
6190        );
6191    }
6192
6193    #[test]
6194    fn cap_exit_fallback_usage_advice_and_salvage() {
6195        // wasted_calls < rounds → the standard "raise max_tool_rounds" advice.
6196        let with = cap_exit_fallback(
6197            4,
6198            Some(crate::TokenUsage {
6199                input_tokens: 12,
6200                output_tokens: 34,
6201            }),
6202            0,
6203            None,
6204        );
6205        assert!(with.contains("12 in / 34 out tokens"), "got: {with}");
6206        assert!(with.contains("max_tool_rounds"), "got: {with}");
6207
6208        let without = cap_exit_fallback(4, None, 0, None);
6209        assert!(!without.contains("tokens consumed"), "got: {without}");
6210        assert!(without.contains("tool-call limit of 4"), "got: {without}");
6211
6212        // Step 27.5: a thrash run (≥ one failed call per round) gets HONEST
6213        // advice — a tooling problem, not "raise the cap".
6214        let thrash = cap_exit_fallback(4, None, 6, None);
6215        assert!(thrash.contains("tool calls that failed"), "got: {thrash}");
6216        assert!(
6217            !thrash.contains("raise [tui].max_tool_rounds"),
6218            "thrash advice must not blame the cap: {thrash}"
6219        );
6220
6221        // Step 27.5: progress is salvaged even when the summary failed.
6222        let salvaged = cap_exit_fallback(4, None, 0, Some("<state>cwd=/x</state>"));
6223        assert!(salvaged.contains("Progress captured"), "got: {salvaged}");
6224        assert!(
6225            salvaged.contains("<state>cwd=/x</state>"),
6226            "got: {salvaged}"
6227        );
6228    }
6229
6230    #[test]
6231    fn cap_exit_summary_action_handoff_is_rejected() {
6232        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.";
6233        assert!(cap_exit_summary_is_action_handoff(handoff));
6234        assert!(!cap_exit_summary_is_action_handoff(
6235            "The duplicate helper definitions and stray brace were removed, and the build check passed."
6236        ));
6237
6238        let fallback = cap_exit_action_handoff_fallback(
6239            25,
6240            None,
6241            2,
6242            Some("<plan>1. [ ] fix duplicate helper definitions</plan>"),
6243        );
6244        assert!(fallback.contains("tool-call limit of 25"), "{fallback}");
6245        assert!(
6246            fallback.contains("described future tool actions"),
6247            "{fallback}"
6248        );
6249        assert!(
6250            fallback.contains("preserved the verified progress"),
6251            "{fallback}"
6252        );
6253        assert!(
6254            !fallback.contains("final summarization request also failed"),
6255            "{fallback}"
6256        );
6257        assert!(
6258            fallback.contains("Progress captured at the tool-call limit"),
6259            "{fallback}"
6260        );
6261    }
6262
6263    #[test]
6264    fn read_only_tools_classified_correctly() {
6265        // save_note writes memory, not the workspace: a round that only
6266        // saved a note must still count toward the read-only write-nudge.
6267        for name in &[
6268            "list_dir",
6269            "read_file",
6270            "find",
6271            "search",
6272            "web_fetch",
6273            "use_skill",
6274            "save_note",
6275        ] {
6276            assert!(is_read_only_tool(name), "{name} should be read-only");
6277        }
6278    }
6279
6280    #[test]
6281    fn write_tools_not_read_only() {
6282        for name in &["edit_file", "write_file", "run_command"] {
6283            assert!(!is_read_only_tool(name), "{name} should NOT be read-only");
6284        }
6285    }
6286
6287    #[test]
6288    fn read_only_call_classifies_simple_shell_probes() {
6289        assert!(is_read_only_call(
6290            "run_command",
6291            &serde_json::json!({"command": "grep -n 'help_lines' newt-tui/src/lib.rs"})
6292        ));
6293        assert!(is_read_only_call(
6294            "run_command",
6295            &serde_json::json!({"command": "rg -n format_help newt-tui/src"})
6296        ));
6297        assert!(is_read_only_call(
6298            "run_command",
6299            &serde_json::json!({"command": "sed -n '1,20p' newt-tui/src/lib.rs"})
6300        ));
6301
6302        assert!(!is_read_only_call(
6303            "run_command",
6304            &serde_json::json!({"command": "cargo test -p newt-tui"})
6305        ));
6306        assert!(!is_read_only_call(
6307            "run_command",
6308            &serde_json::json!({"command": "sed -i 's/a/b/' file.txt"})
6309        ));
6310        assert!(!is_read_only_call(
6311            "run_command",
6312            &serde_json::json!({"command": "grep x file > out.txt"})
6313        ));
6314    }
6315
6316    #[test]
6317    fn read_only_action_nudge_names_edit_permission_and_blocker_paths() {
6318        let nudge = read_only_action_nudge(3, 4, None, None);
6319        assert!(nudge.contains("read-only rounds so far"), "{nudge}");
6320        assert!(nudge.contains("edit_file"), "{nudge}");
6321        assert!(nudge.contains("write_file"), "{nudge}");
6322        assert!(nudge.contains("request_permissions"), "{nudge}");
6323        assert!(nudge.contains("exact blocker"), "{nudge}");
6324    }
6325
6326    #[test]
6327    fn read_only_action_nudge_mentions_active_plan_when_present() {
6328        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6329
6330        let ledger = SessionStepLedger::default();
6331        ledger.restore(&PlanSnapshot {
6332            steps: vec![
6333                Step {
6334                    description: "inspect".to_string(),
6335                    status: StepStatus::Done,
6336                },
6337                Step {
6338                    description: "edit".to_string(),
6339                    status: StepStatus::Active,
6340                },
6341            ],
6342        });
6343        let nudge = read_only_action_nudge(3, 2, Some(&ledger as &dyn StepLedger), None);
6344        assert!(nudge.contains("active multi-step plan"), "{nudge}");
6345        assert!(nudge.contains("ACTIVE step"), "{nudge}");
6346    }
6347
6348    /// #<issue>: when a `WorkflowSteerer` match offers a delegate hint (e.g.
6349    /// the built-in `diagnose_failure` workflow, and `crew`/`team` dispatch is
6350    /// available this session), the read-only nudge surfaces it — sustained
6351    /// read-only exploration on that task shape is exactly what delegation is
6352    /// for, not just "stop reading, edit it yourself".
6353    #[test]
6354    fn read_only_action_nudge_includes_a_delegate_hint_when_offered() {
6355        let nudge = read_only_action_nudge(3, 4, None, Some("consider calling crew or team"));
6356        assert!(nudge.contains("consider calling crew or team"), "{nudge}");
6357        // Still carries the original inline-action guidance too — delegation
6358        // is offered ALONGSIDE continuing directly, never in place of it.
6359        assert!(nudge.contains("edit_file"), "{nudge}");
6360    }
6361
6362    #[test]
6363    fn read_only_action_nudge_omits_delegate_clause_when_none_offered() {
6364        let nudge = read_only_action_nudge(3, 4, None, None);
6365        assert!(!nudge.contains("crew"), "{nudge}");
6366        assert!(!nudge.contains("team"), "{nudge}");
6367    }
6368
6369    #[test]
6370    fn pending_plan_completion_nudge_is_state_driven() {
6371        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
6372
6373        assert!(pending_plan_completion_nudge(None, false, None).is_none());
6374
6375        let ledger = SessionStepLedger::default();
6376        ledger.restore(&PlanSnapshot {
6377            steps: vec![
6378                Step {
6379                    description: "already done".to_string(),
6380                    status: StepStatus::Done,
6381                },
6382                Step {
6383                    description: "keep working".to_string(),
6384                    status: StepStatus::Active,
6385                },
6386            ],
6387        });
6388        let nudge = pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None)
6389            .expect("open plan produces a nudge");
6390        assert!(nudge.contains("1/2 unfinished step"), "{nudge}");
6391        assert!(nudge.contains("Active step: 'keep working'"), "{nudge}");
6392        assert!(nudge.contains("update_plan"), "{nudge}");
6393        assert!(nudge.contains("call the next tool"), "{nudge}");
6394        assert!(nudge.contains("concrete blocker"), "{nudge}");
6395
6396        let plan_update_nudge = pending_plan_completion_nudge(
6397            Some(&ledger as &dyn StepLedger),
6398            true,
6399            Some(
6400                "Configured workflow 'github_pr' is active. Workflow steps:\n- commit_step: Commit the verified step",
6401            ),
6402        )
6403        .expect("open plan produces a plan-update nudge");
6404        assert!(
6405            plan_update_nudge.contains("findings/next-steps summary"),
6406            "{plan_update_nudge}"
6407        );
6408        assert!(
6409            plan_update_nudge.contains("Call update_plan now"),
6410            "{plan_update_nudge}"
6411        );
6412        assert!(
6413            plan_update_nudge.contains("make the immediate blocker repair the active step"),
6414            "{plan_update_nudge}"
6415        );
6416        assert!(
6417            plan_update_nudge.contains("Do not repeat the findings summary"),
6418            "{plan_update_nudge}"
6419        );
6420        assert!(
6421            plan_update_nudge.contains("github_pr"),
6422            "{plan_update_nudge}"
6423        );
6424        assert!(
6425            plan_update_nudge.contains("commit_step"),
6426            "{plan_update_nudge}"
6427        );
6428
6429        ledger.restore(&PlanSnapshot {
6430            steps: vec![Step {
6431                description: "complete".to_string(),
6432                status: StepStatus::Done,
6433            }],
6434        });
6435        assert!(
6436            pending_plan_completion_nudge(Some(&ledger as &dyn StepLedger), false, None).is_none()
6437        );
6438    }
6439
6440    #[test]
6441    fn workflow_classifier_text_keeps_recent_user_issue_context() {
6442        let messages = vec![
6443            serde_json::json!({
6444                "role": "user",
6445                "content": "Take a look at https://github.com/Gilamonster-Foundation/newt-agent/issues/548 and get me a PR."
6446            }),
6447            serde_json::json!({
6448                "role": "assistant",
6449                "content": "I will inspect the issue and repo state."
6450            }),
6451        ];
6452        let text = workflow_classifier_text(
6453            &messages,
6454            "Summary of Findings\n\nCurrent Status: the build is broken. Next Steps Required: update the plan.",
6455        );
6456        let hint = crate::WorkflowSteerer::builtin()
6457            .plan_update_hint(&text)
6458            .expect("GitHub issue context should select the PR workflow");
6459        assert!(hint.contains("github_pr"), "{hint}");
6460        assert!(hint.contains("read_issue"), "{hint}");
6461        assert!(hint.contains("open_pr"), "{hint}");
6462    }
6463}
6464
6465// ---------------------------------------------------------------------------
6466// Tool-call round cap + graceful cap-exit (issue: configurable max_tool_rounds)
6467// ---------------------------------------------------------------------------
6468//
6469// These tests exercise both agentic loops (`chat_complete` -> Ollama path and
6470// `openai_chat_complete`) against a wiremock backend. The mock returns tool
6471// calls while `tools` are present in the request and a real text answer once
6472// they are absent — letting us assert that:
6473//   (1) the loop honours the configured `max_tool_rounds` cap, and
6474//   (2) on hitting the cap newt issues ONE final tools-disabled completion and
6475//       returns its text (NOT the `(reached tool-call limit)` placeholder).
6476//
6477// (The companion test that recovers a hard context-window 400 via the
6478// `recover_cw_400` hook lives in newt-tui — it exercises the TUI-side probe
6479// cache persistence under a HOME env guard.)
6480#[cfg(test)]
6481mod tool_round_cap_tests {
6482    use super::*;
6483    use crate::caveats::Caveats;
6484    use crate::{BackendKind, MemMessage};
6485    use std::sync::atomic::{AtomicUsize, Ordering};
6486    use std::sync::Arc;
6487    use wiremock::matchers::{method, path};
6488    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
6489
6490    /// Was the `"tools"` key present on this request body?
6491    fn request_has_tools(req: &Request) -> bool {
6492        serde_json::from_slice::<serde_json::Value>(&req.body)
6493            .ok()
6494            .map(|v| v.get("tools").is_some())
6495            .unwrap_or(false)
6496    }
6497
6498    /// Ollama-shaped responder: returns a tool call whenever `tools` are
6499    /// offered, and a plain text answer once they are withheld. Counts the
6500    /// number of tool-offering requests it served.
6501    struct OllamaResponder {
6502        tool_rounds_served: Arc<AtomicUsize>,
6503        final_answer: String,
6504    }
6505
6506    impl Respond for OllamaResponder {
6507        fn respond(&self, req: &Request) -> ResponseTemplate {
6508            if request_has_tools(req) {
6509                self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6510                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6511                    "message": {
6512                        "content": "",
6513                        "tool_calls": [{
6514                            "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
6515                        }]
6516                    }
6517                }))
6518            } else {
6519                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6520                    "message": { "content": self.final_answer }
6521                }))
6522            }
6523        }
6524    }
6525
6526    /// OpenAI-shaped responder: same logic, OpenAI `choices[0].message` shape.
6527    struct OpenAiResponder {
6528        tool_rounds_served: Arc<AtomicUsize>,
6529        final_answer: String,
6530    }
6531
6532    impl Respond for OpenAiResponder {
6533        fn respond(&self, req: &Request) -> ResponseTemplate {
6534            if request_has_tools(req) {
6535                self.tool_rounds_served.fetch_add(1, Ordering::SeqCst);
6536                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6537                    "choices": [{ "message": {
6538                        "content": null,
6539                        "tool_calls": [{
6540                            "id": "call_1",
6541                            "type": "function",
6542                            "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
6543                        }]
6544                    }}]
6545                }))
6546            } else {
6547                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6548                    "choices": [{ "message": { "content": self.final_answer } }]
6549                }))
6550            }
6551        }
6552    }
6553
6554    fn msgs() -> Vec<MemMessage> {
6555        vec![
6556            MemMessage::system("you are a test"),
6557            MemMessage::user("do the thing"),
6558        ]
6559    }
6560
6561    #[tokio::test]
6562    async fn ollama_loop_honors_configured_cap_and_returns_real_final_answer() {
6563        let server = MockServer::start().await;
6564        let served = Arc::new(AtomicUsize::new(0));
6565        Mock::given(method("POST"))
6566            .and(path("/api/chat"))
6567            .respond_with(OllamaResponder {
6568                tool_rounds_served: served.clone(),
6569                final_answer: "here is my partial summary".into(),
6570            })
6571            .mount(&server)
6572            .await;
6573
6574        let messages = msgs();
6575        let caveats = Caveats::top();
6576        let cap = 3;
6577        let mut end_reason: Option<crate::TurnEndReason> = None;
6578        let (reply, streamed, _usage, _hallu) = chat_complete(
6579            ChatCtx {
6580                url: &server.uri(),
6581                model: "test-model",
6582                kind: BackendKind::Ollama,
6583                api_key: None,
6584                messages: &messages,
6585                task: "do the thing",
6586                workspace: ".",
6587                color: false,
6588                markdown: false,
6589                tool_offload: false,
6590                spill_store: None,
6591                compaction_store: None,
6592                scratchpad: false,
6593                scratchpad_store: None,
6594                code_search: None,
6595                experience_store: None,
6596                step_ledger: None,
6597                caveats: &caveats,
6598                persona_tools: None,
6599                max_tool_rounds: cap,
6600                narration_nudge_cap: 1,
6601                workflow_grace_rounds: 0,
6602                tool_output_lines: 20,
6603                debug: false,
6604                trace: false,
6605                num_ctx: None,
6606                input_ceiling_pct: 80,
6607                low_budget_pct: 15,
6608                connect_timeout_secs: 5,
6609                inference_timeout_secs: 120,
6610                mid_loop_trim_threshold: 40,
6611                mid_loop_trim_tokens: None,
6612                max_ok_input: None,
6613                build_check_cmd: None,
6614                safe_context: None,
6615                recover_cw_400: None,
6616                note_sink: None,
6617                note_nudge: None,
6618                recall_source: None,
6619                memory_source: None,
6620                summarizer: None,
6621                compress_state: None,
6622                tool_events: None,
6623                phantom_reaches: None,
6624                end_reason: Some(&mut end_reason),
6625                permission_gate: None,
6626                on_round_usage: None,
6627                estimate_ratio: None,
6628                estimation: crate::tokens::TokenEstimation::default(),
6629                summary_input_cap_floor_chars: 8_192,
6630                exec_floor: None,
6631                write_ledger: None,
6632                cancel: None,
6633                git_tool: None,
6634                crew_runner: None,
6635            },
6636            &mut NoMcp,
6637        )
6638        .await
6639        .expect("chat_complete should succeed");
6640
6641        // The cap was honoured: exactly `cap` tool-offering rounds were served.
6642        assert_eq!(served.load(Ordering::SeqCst), cap);
6643        // The cap-exit issued a final tools-disabled completion and returned
6644        // its text — NOT the dead placeholder.
6645        assert_eq!(reply, "here is my partial summary");
6646        assert_ne!(reply, "(reached tool-call limit)");
6647        assert!(!streamed);
6648        // The cap exit reports itself (acceptance forensics, commit 4).
6649        assert_eq!(end_reason, Some(crate::TurnEndReason::RoundCap));
6650    }
6651
6652    #[tokio::test]
6653    async fn ollama_cap_exit_rejects_action_intent_summary() {
6654        let server = MockServer::start().await;
6655        Mock::given(method("POST"))
6656            .and(path("/api/chat"))
6657            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6658                "message": {
6659                    "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."
6660                }
6661            })))
6662            .mount(&server)
6663            .await;
6664
6665        let client = reqwest::Client::new();
6666        let chat_url = format!("{}/api/chat", server.uri());
6667        let (reply, streamed, _usage) = final_summary_ollama(
6668            &client,
6669            &chat_url,
6670            "test-model",
6671            Vec::new(),
6672            CapExit {
6673                max_tool_rounds: 25,
6674                accumulated: None,
6675                wasted_calls: 0,
6676                progress: Some("<plan>1. [ ] fix duplicate helper definitions</plan>".to_string()),
6677                observed: Vec::new(),
6678            },
6679        )
6680        .await
6681        .expect("final summary helper should return a fallback");
6682
6683        assert!(!streamed);
6684        assert!(reply.contains("tool-call limit of 25"), "{reply}");
6685        assert!(reply.contains("described future tool actions"), "{reply}");
6686        assert!(reply.contains("preserved the verified progress"), "{reply}");
6687        assert!(
6688            !reply.contains("Let me fix both"),
6689            "must not accept action-intent cap summary: {reply}"
6690        );
6691        assert!(
6692            !reply.contains("final summarization request also failed"),
6693            "{reply}"
6694        );
6695        assert!(
6696            reply.contains("Progress captured at the tool-call limit"),
6697            "{reply}"
6698        );
6699    }
6700
6701    /// UAT (Step 27.3 + 27.5, simulated integration): a thrash run — a DISTINCT
6702    /// failing tool call every round (so the failed-call count climbs to the
6703    /// cap) AND a final summary that also errors. The cap-exit must be HONEST:
6704    /// name the tooling problem, never advise "raise max_tool_rounds".
6705    struct ThrashResponder {
6706        round: AtomicUsize,
6707    }
6708
6709    impl Respond for ThrashResponder {
6710        fn respond(&self, req: &Request) -> ResponseTemplate {
6711            if request_has_tools(req) {
6712                let n = self.round.fetch_add(1, Ordering::SeqCst);
6713                // A distinct unknown tool each round → each fails and is NOT a
6714                // repeat, so the guard records every one (wasted_calls climbs to
6715                // the cap, which is what flips the cap-exit to honest advice).
6716                ResponseTemplate::new(200).set_body_json(serde_json::json!({
6717                    "message": {
6718                        "content": "",
6719                        "tool_calls": [{
6720                            "function": { "name": format!("bogus_tool_{n}"), "arguments": {} }
6721                        }]
6722                    }
6723                }))
6724            } else {
6725                // The final tools-disabled summary request ALSO fails (500),
6726                // forcing the cap_exit_fallback path.
6727                ResponseTemplate::new(500).set_body_string("model exploded")
6728            }
6729        }
6730    }
6731
6732    #[tokio::test]
6733    async fn uat_thrash_run_gets_honest_cap_exit_not_raise_the_limit() {
6734        let server = MockServer::start().await;
6735        Mock::given(method("POST"))
6736            .and(path("/api/chat"))
6737            .respond_with(ThrashResponder {
6738                round: AtomicUsize::new(0),
6739            })
6740            .mount(&server)
6741            .await;
6742
6743        let messages = msgs();
6744        let caveats = Caveats::top();
6745        let cap = 3;
6746        let (reply, _streamed, _usage, hallu) = chat_complete(
6747            ChatCtx {
6748                url: &server.uri(),
6749                model: "test-model",
6750                kind: BackendKind::Ollama,
6751                api_key: None,
6752                messages: &messages,
6753                task: "do the thing",
6754                workspace: ".",
6755                color: false,
6756                markdown: false,
6757                tool_offload: false,
6758                spill_store: None,
6759                compaction_store: None,
6760                scratchpad: false,
6761                scratchpad_store: None,
6762                code_search: None,
6763                experience_store: None,
6764                step_ledger: None,
6765                caveats: &caveats,
6766                persona_tools: None,
6767                max_tool_rounds: cap,
6768                narration_nudge_cap: 1,
6769                workflow_grace_rounds: 0,
6770                tool_output_lines: 20,
6771                debug: false,
6772                trace: false,
6773                num_ctx: None,
6774                input_ceiling_pct: 80,
6775                low_budget_pct: 15,
6776                connect_timeout_secs: 5,
6777                inference_timeout_secs: 120,
6778                mid_loop_trim_threshold: 40,
6779                mid_loop_trim_tokens: None,
6780                max_ok_input: None,
6781                build_check_cmd: None,
6782                safe_context: None,
6783                recover_cw_400: None,
6784                note_sink: None,
6785                note_nudge: None,
6786                recall_source: None,
6787                memory_source: None,
6788                summarizer: None,
6789                compress_state: None,
6790                tool_events: None,
6791                phantom_reaches: None,
6792                end_reason: None,
6793                permission_gate: None,
6794                on_round_usage: None,
6795                estimate_ratio: None,
6796                estimation: crate::tokens::TokenEstimation::default(),
6797                summary_input_cap_floor_chars: 8_192,
6798                exec_floor: None,
6799                write_ledger: None,
6800                cancel: None,
6801                git_tool: None,
6802                crew_runner: None,
6803            },
6804            &mut NoMcp,
6805        )
6806        .await
6807        .expect("chat_complete should succeed even when the summary fails");
6808
6809        // Every round emitted a (distinct) bogus call → counted as a hallucination.
6810        assert_eq!(hallu, cap as u32, "each round hallucinated a tool");
6811        // Step 27.5: the cap-exit is HONEST — a tooling problem, NOT "raise the cap".
6812        assert!(
6813            reply.contains("tool calls that failed"),
6814            "honest advice expected, got: {reply}"
6815        );
6816        assert!(
6817            !reply.contains("raise [tui].max_tool_rounds"),
6818            "must not blame the round cap on a thrash run: {reply}"
6819        );
6820    }
6821
6822    #[tokio::test]
6823    async fn a_set_cancel_flag_abandons_the_turn_before_any_network_call() {
6824        // The interrupt checkpoint at the round-loop top runs before the first
6825        // request, so a pre-tripped flag returns instantly — the bogus URL
6826        // (a closed port) is never contacted. If the checkpoint regressed,
6827        // the dispatch would try to connect and this would not return empty.
6828        let messages = msgs();
6829        let caveats = Caveats::top();
6830        let flag = std::sync::atomic::AtomicBool::new(true);
6831        let (reply, streamed, usage, hallu) = chat_complete(
6832            ChatCtx {
6833                url: "http://127.0.0.1:1",
6834                model: "test-model",
6835                kind: BackendKind::Ollama,
6836                api_key: None,
6837                messages: &messages,
6838                task: "do the thing",
6839                workspace: ".",
6840                color: false,
6841                markdown: false,
6842                tool_offload: false,
6843                spill_store: None,
6844                compaction_store: None,
6845                scratchpad: false,
6846                scratchpad_store: None,
6847                code_search: None,
6848                experience_store: None,
6849                step_ledger: None,
6850                caveats: &caveats,
6851                persona_tools: None,
6852                max_tool_rounds: 5,
6853                narration_nudge_cap: 1,
6854                workflow_grace_rounds: 0,
6855                tool_output_lines: 20,
6856                debug: false,
6857                trace: false,
6858                num_ctx: None,
6859                input_ceiling_pct: 80,
6860                low_budget_pct: 15,
6861                connect_timeout_secs: 5,
6862                inference_timeout_secs: 120,
6863                mid_loop_trim_threshold: 40,
6864                mid_loop_trim_tokens: None,
6865                max_ok_input: None,
6866                build_check_cmd: None,
6867                safe_context: None,
6868                recover_cw_400: None,
6869                note_sink: None,
6870                note_nudge: None,
6871                recall_source: None,
6872                memory_source: None,
6873                summarizer: None,
6874                compress_state: None,
6875                tool_events: None,
6876                phantom_reaches: None,
6877                end_reason: None,
6878                permission_gate: None,
6879                on_round_usage: None,
6880                estimate_ratio: None,
6881                estimation: crate::tokens::TokenEstimation::default(),
6882                summary_input_cap_floor_chars: 8_192,
6883                exec_floor: None,
6884                write_ledger: None,
6885                cancel: Some(&flag),
6886                git_tool: None,
6887                crew_runner: None,
6888            },
6889            &mut NoMcp,
6890        )
6891        .await
6892        .expect("an interrupted turn still returns Ok, just empty");
6893        assert!(reply.is_empty(), "interrupted before any model output");
6894        assert!(!streamed);
6895        assert!(usage.is_none());
6896        assert_eq!(hallu, 0);
6897    }
6898
6899    #[test]
6900    fn responses_input_splits_system_to_instructions_and_passes_typed_items() {
6901        let msgs = vec![
6902            serde_json::json!({"role": "system", "content": "be terse"}),
6903            serde_json::json!({"role": "user", "content": "hi"}),
6904            serde_json::json!({"role": "assistant", "content": "hello"}),
6905            // an already-typed Responses item passes through untouched
6906            serde_json::json!({"type": "function_call_output", "call_id": "c1", "output": "ok"}),
6907        ];
6908        let (instructions, input) = build_responses_input(&msgs);
6909        assert_eq!(instructions.as_deref(), Some("be terse"));
6910        assert_eq!(input.len(), 3);
6911        assert_eq!(input[0]["role"], "user");
6912        assert_eq!(input[0]["content"], "hi");
6913        assert_eq!(input[2]["type"], "function_call_output");
6914    }
6915
6916    #[test]
6917    fn tools_flatten_to_responses_shape() {
6918        let chat = serde_json::json!([{
6919            "type": "function",
6920            "function": {
6921                "name": "git",
6922                "description": "run git",
6923                "parameters": {"type": "object"}
6924            }
6925        }]);
6926        let out = tools_to_responses(&chat);
6927        assert_eq!(out.len(), 1);
6928        assert_eq!(out[0]["type"], "function");
6929        assert_eq!(
6930            out[0]["name"], "git",
6931            "name hoisted out of the function wrapper"
6932        );
6933        assert_eq!(out[0]["description"], "run git");
6934        assert!(out[0]["function"].is_null(), "no nested function wrapper");
6935    }
6936
6937    #[test]
6938    fn parse_responses_output_extracts_text_calls_and_usage() {
6939        let json = serde_json::json!({
6940            "output": [
6941                {"type": "reasoning", "summary": "…"},
6942                {"type": "message", "role": "assistant",
6943                 "content": [{"type": "output_text", "text": "the answer"}]},
6944                {"type": "function_call", "call_id": "call_1", "name": "git",
6945                 "arguments": "{\"op\":\"status\"}"}
6946            ],
6947            "usage": {"input_tokens": 100, "output_tokens": 20}
6948        });
6949        let (text, calls) = parse_responses_output(&json);
6950        assert_eq!(text, "the answer");
6951        assert_eq!(calls.len(), 1);
6952        assert_eq!(calls[0]["call_id"], "call_1");
6953        let usage = responses_usage(&json["usage"]).unwrap();
6954        assert_eq!(usage.input_tokens, 100);
6955        assert_eq!(usage.output_tokens, 20);
6956    }
6957
6958    #[tokio::test]
6959    async fn responses_loop_returns_message_text_from_v1_responses() {
6960        let server = MockServer::start().await;
6961        Mock::given(method("POST"))
6962            .and(path("/v1/responses"))
6963            .respond_with(
6964                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6965                    "output": [{
6966                        "type": "message", "role": "assistant",
6967                        "content": [{"type": "output_text", "text": "hello from responses"}]
6968                    }],
6969                    "usage": {"input_tokens": 12, "output_tokens": 4}
6970                })),
6971            )
6972            .mount(&server)
6973            .await;
6974
6975        let messages = msgs();
6976        let caveats = Caveats::top();
6977        let (reply, streamed, usage, _hallu) = openai_responses_complete(
6978            ChatCtx {
6979                url: &server.uri(),
6980                model: "gpt-5-codex",
6981                kind: BackendKind::Openai,
6982                api_key: Some("sk-test"),
6983                messages: &messages,
6984                task: "do the thing",
6985                workspace: ".",
6986                color: false,
6987                markdown: false,
6988                tool_offload: false,
6989                spill_store: None,
6990                compaction_store: None,
6991                scratchpad: false,
6992                scratchpad_store: None,
6993                code_search: None,
6994                experience_store: None,
6995                step_ledger: None,
6996                caveats: &caveats,
6997                persona_tools: None,
6998                max_tool_rounds: 5,
6999                narration_nudge_cap: 1,
7000                workflow_grace_rounds: 0,
7001                tool_output_lines: 20,
7002                debug: false,
7003                trace: false,
7004                num_ctx: None,
7005                input_ceiling_pct: 80,
7006                low_budget_pct: 15,
7007                connect_timeout_secs: 5,
7008                inference_timeout_secs: 120,
7009                mid_loop_trim_threshold: 40,
7010                mid_loop_trim_tokens: None,
7011                max_ok_input: None,
7012                build_check_cmd: None,
7013                safe_context: None,
7014                recover_cw_400: None,
7015                note_sink: None,
7016                note_nudge: None,
7017                recall_source: None,
7018                memory_source: None,
7019                summarizer: None,
7020                compress_state: None,
7021                tool_events: None,
7022                phantom_reaches: None,
7023                end_reason: None,
7024                permission_gate: None,
7025                on_round_usage: None,
7026                estimate_ratio: None,
7027                estimation: crate::tokens::TokenEstimation::default(),
7028                summary_input_cap_floor_chars: 8_192,
7029                exec_floor: None,
7030                write_ledger: None,
7031                cancel: None,
7032                git_tool: None,
7033                crew_runner: None,
7034            },
7035            &mut NoMcp,
7036        )
7037        .await
7038        .expect("responses loop returns the message text");
7039        assert_eq!(reply, "hello from responses");
7040        assert!(!streamed);
7041        assert_eq!(usage.map(|u| u.input_tokens), Some(12));
7042    }
7043
7044    #[tokio::test]
7045    async fn openai_loop_honors_configured_cap_and_returns_real_final_answer() {
7046        let server = MockServer::start().await;
7047        let served = Arc::new(AtomicUsize::new(0));
7048        Mock::given(method("POST"))
7049            .and(path("/v1/chat/completions"))
7050            .respond_with(OpenAiResponder {
7051                tool_rounds_served: served.clone(),
7052                final_answer: "openai partial answer".into(),
7053            })
7054            .mount(&server)
7055            .await;
7056
7057        let messages = msgs();
7058        let caveats = Caveats::top();
7059        let cap = 2;
7060        let (reply, streamed, _usage, _hallu) = openai_chat_complete(
7061            ChatCtx {
7062                url: &server.uri(),
7063                model: "test-model",
7064                kind: BackendKind::Openai,
7065                api_key: Some("sk-test"),
7066                messages: &messages,
7067                task: "do the thing",
7068                workspace: ".",
7069                color: false,
7070                markdown: false,
7071                tool_offload: false,
7072                spill_store: None,
7073                compaction_store: None,
7074                scratchpad: false,
7075                scratchpad_store: None,
7076                code_search: None,
7077                experience_store: None,
7078                step_ledger: None,
7079                caveats: &caveats,
7080                persona_tools: None,
7081                max_tool_rounds: cap,
7082                narration_nudge_cap: 1,
7083                workflow_grace_rounds: 0,
7084                tool_output_lines: 20,
7085                debug: false,
7086                trace: false,
7087                num_ctx: None,
7088                input_ceiling_pct: 80,
7089                low_budget_pct: 15,
7090                connect_timeout_secs: 5,
7091                inference_timeout_secs: 120,
7092                mid_loop_trim_threshold: 40,
7093                mid_loop_trim_tokens: None,
7094                max_ok_input: None,
7095                build_check_cmd: None,
7096                safe_context: None,
7097                recover_cw_400: None,
7098                note_sink: None,
7099                note_nudge: None,
7100                recall_source: None,
7101                memory_source: None,
7102                summarizer: None,
7103                compress_state: None,
7104                tool_events: None,
7105                phantom_reaches: None,
7106                end_reason: None,
7107                permission_gate: None,
7108                on_round_usage: None,
7109                estimate_ratio: None,
7110                estimation: crate::tokens::TokenEstimation::default(),
7111                summary_input_cap_floor_chars: 8_192,
7112                exec_floor: None,
7113                write_ledger: None,
7114                cancel: None,
7115                git_tool: None,
7116                crew_runner: None,
7117            },
7118            &mut NoMcp,
7119        )
7120        .await
7121        .expect("openai_chat_complete should succeed");
7122
7123        assert_eq!(served.load(Ordering::SeqCst), cap);
7124        assert_eq!(reply, "openai partial answer");
7125        assert_ne!(reply, "(reached tool-call limit)");
7126        assert!(!streamed);
7127    }
7128
7129    /// 17.6: with a recorder lent in `ChatCtx.tool_events`, the Ollama loop
7130    /// records one event per executed tool call — name as invoked, digested
7131    /// args (keys + hash, never raw values), best-effort outcome, duration
7132    /// claim. Without a recorder (every other test here) nothing changes.
7133    #[tokio::test]
7134    async fn ollama_loop_records_tool_events_with_digested_args() {
7135        let server = MockServer::start().await;
7136        struct TwoToolResponder;
7137        impl Respond for TwoToolResponder {
7138            fn respond(&self, req: &Request) -> ResponseTemplate {
7139                if request_has_tools(req) {
7140                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7141                        "message": { "content": "", "tool_calls": [
7142                            { "function": { "name": "list_dir",
7143                                            "arguments": {"path": "."} } },
7144                            { "function": { "name": "definitely_not_a_real_tool",
7145                                            "arguments": {"token": "tippy-top-secret"} } }
7146                        ]}
7147                    }))
7148                } else {
7149                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7150                        "message": { "content": "done" }
7151                    }))
7152                }
7153            }
7154        }
7155        Mock::given(method("POST"))
7156            .and(path("/api/chat"))
7157            .respond_with(TwoToolResponder)
7158            .mount(&server)
7159            .await;
7160
7161        let ws = tempfile::TempDir::new().unwrap();
7162        let workspace = ws.path().to_string_lossy().into_owned();
7163        let messages = msgs();
7164        let caveats = Caveats::top();
7165        let mut events: Vec<crate::ToolEvent> = Vec::new();
7166        chat_complete(
7167            ChatCtx {
7168                url: &server.uri(),
7169                model: "test-model",
7170                kind: BackendKind::Ollama,
7171                api_key: None,
7172                messages: &messages,
7173                task: "do the thing",
7174                workspace: &workspace,
7175                color: false,
7176                markdown: false,
7177                tool_offload: false,
7178                spill_store: None,
7179                compaction_store: None,
7180                scratchpad: false,
7181                scratchpad_store: None,
7182                code_search: None,
7183                experience_store: None,
7184                step_ledger: None,
7185                caveats: &caveats,
7186                persona_tools: None,
7187                max_tool_rounds: 1,
7188                narration_nudge_cap: 1,
7189                workflow_grace_rounds: 0,
7190                tool_output_lines: 20,
7191                debug: false,
7192                trace: false,
7193                num_ctx: None,
7194                input_ceiling_pct: 80,
7195                low_budget_pct: 15,
7196                connect_timeout_secs: 5,
7197                inference_timeout_secs: 120,
7198                mid_loop_trim_threshold: 40,
7199                mid_loop_trim_tokens: None,
7200                max_ok_input: None,
7201                build_check_cmd: None,
7202                safe_context: None,
7203                recover_cw_400: None,
7204                note_sink: None,
7205                note_nudge: None,
7206                recall_source: None,
7207                memory_source: None,
7208                summarizer: None,
7209                compress_state: None,
7210                tool_events: Some(&mut events),
7211                phantom_reaches: None,
7212                end_reason: None,
7213                permission_gate: None,
7214                on_round_usage: None,
7215                estimate_ratio: None,
7216                estimation: crate::tokens::TokenEstimation::default(),
7217                summary_input_cap_floor_chars: 8_192,
7218                exec_floor: None,
7219                write_ledger: None,
7220                cancel: None,
7221                git_tool: None,
7222                crew_runner: None,
7223            },
7224            &mut NoMcp,
7225        )
7226        .await
7227        .expect("chat_complete should succeed");
7228
7229        assert_eq!(events.len(), 2, "one event per tool call: {events:?}");
7230        assert_eq!(events[0].tool, "list_dir");
7231        assert!(events[0].ok, "a real listing reads as success");
7232        assert!(events[0].args_digest.contains("path"));
7233        assert!(events[0].duration_ms.is_some());
7234        assert_eq!(events[1].tool, "definitely_not_a_real_tool");
7235        assert!(!events[1].ok, "an unknown tool reads as failure");
7236        // Args are digested, never recorded raw.
7237        assert!(events[1].args_digest.contains("token"));
7238        assert!(
7239            !events[1].args_digest.contains("tippy-top-secret"),
7240            "raw arg value leaked: {}",
7241            events[1].args_digest
7242        );
7243    }
7244
7245    /// 17.6: the OpenAI loop records the same per-call events (its tool
7246    /// arguments arrive as a JSON *string* — the digest must match the
7247    /// parsed-args digest the Ollama path produces for identical args).
7248    #[tokio::test]
7249    async fn openai_loop_records_tool_events_with_digested_args() {
7250        let server = MockServer::start().await;
7251        struct OneToolResponder;
7252        impl Respond for OneToolResponder {
7253            fn respond(&self, req: &Request) -> ResponseTemplate {
7254                if request_has_tools(req) {
7255                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7256                        "choices": [{ "message": {
7257                            "content": null,
7258                            "tool_calls": [{
7259                                "id": "call_1",
7260                                "type": "function",
7261                                "function": { "name": "list_dir",
7262                                              "arguments": "{\"path\": \".\"}" }
7263                            }]
7264                        }}]
7265                    }))
7266                } else {
7267                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7268                        "choices": [{ "message": { "content": "done" } }]
7269                    }))
7270                }
7271            }
7272        }
7273        Mock::given(method("POST"))
7274            .and(path("/v1/chat/completions"))
7275            .respond_with(OneToolResponder)
7276            .mount(&server)
7277            .await;
7278
7279        let ws = tempfile::TempDir::new().unwrap();
7280        let workspace = ws.path().to_string_lossy().into_owned();
7281        let messages = msgs();
7282        let caveats = Caveats::top();
7283        let mut events: Vec<crate::ToolEvent> = Vec::new();
7284        openai_chat_complete(
7285            ChatCtx {
7286                url: &server.uri(),
7287                model: "test-model",
7288                kind: BackendKind::Openai,
7289                api_key: Some("sk-test"),
7290                messages: &messages,
7291                task: "do the thing",
7292                workspace: &workspace,
7293                color: false,
7294                markdown: false,
7295                tool_offload: false,
7296                spill_store: None,
7297                compaction_store: None,
7298                scratchpad: false,
7299                scratchpad_store: None,
7300                code_search: None,
7301                experience_store: None,
7302                step_ledger: None,
7303                caveats: &caveats,
7304                persona_tools: None,
7305                max_tool_rounds: 1,
7306                narration_nudge_cap: 1,
7307                workflow_grace_rounds: 0,
7308                tool_output_lines: 20,
7309                debug: false,
7310                trace: false,
7311                num_ctx: None,
7312                input_ceiling_pct: 80,
7313                low_budget_pct: 15,
7314                connect_timeout_secs: 5,
7315                inference_timeout_secs: 120,
7316                mid_loop_trim_threshold: 40,
7317                mid_loop_trim_tokens: None,
7318                max_ok_input: None,
7319                build_check_cmd: None,
7320                safe_context: None,
7321                recover_cw_400: None,
7322                note_sink: None,
7323                note_nudge: None,
7324                recall_source: None,
7325                memory_source: None,
7326                summarizer: None,
7327                compress_state: None,
7328                tool_events: Some(&mut events),
7329                phantom_reaches: None,
7330                end_reason: None,
7331                permission_gate: None,
7332                on_round_usage: None,
7333                estimate_ratio: None,
7334                estimation: crate::tokens::TokenEstimation::default(),
7335                summary_input_cap_floor_chars: 8_192,
7336                exec_floor: None,
7337                write_ledger: None,
7338                cancel: None,
7339                git_tool: None,
7340                crew_runner: None,
7341            },
7342            &mut NoMcp,
7343        )
7344        .await
7345        .expect("openai_chat_complete should succeed");
7346
7347        assert_eq!(events.len(), 1, "one event per tool call: {events:?}");
7348        assert_eq!(events[0].tool, "list_dir");
7349        assert!(events[0].ok);
7350        assert_eq!(
7351            events[0].args_digest,
7352            crate::ToolEvent::from_call("x", &serde_json::json!({"path": "."}), true, None)
7353                .args_digest,
7354            "string-encoded args must digest like parsed args"
7355        );
7356    }
7357
7358    #[tokio::test]
7359    async fn cap_exit_fallback_when_final_summary_errors() {
7360        // No mock for the tools-disabled request would still 404 via the
7361        // tool-offering mock only matching when... actually both match the same
7362        // path, so instead we mount a server that always 500s the *second*
7363        // shape. Simpler: a server that returns tool calls for tools-present
7364        // and a 500 for tools-absent, forcing the fallback branch.
7365        let server = MockServer::start().await;
7366        let served = Arc::new(AtomicUsize::new(0));
7367        struct ErrOnFinal {
7368            served: Arc<AtomicUsize>,
7369        }
7370        impl Respond for ErrOnFinal {
7371            fn respond(&self, req: &Request) -> ResponseTemplate {
7372                if request_has_tools(req) {
7373                    self.served.fetch_add(1, Ordering::SeqCst);
7374                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7375                        "message": { "content": "", "tool_calls": [{
7376                            "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
7377                        }]}
7378                    }))
7379                } else {
7380                    ResponseTemplate::new(500).set_body_string("boom")
7381                }
7382            }
7383        }
7384        Mock::given(method("POST"))
7385            .and(path("/api/chat"))
7386            .respond_with(ErrOnFinal {
7387                served: served.clone(),
7388            })
7389            .mount(&server)
7390            .await;
7391
7392        let messages = msgs();
7393        let caveats = Caveats::top();
7394        let (reply, _streamed, _usage, _hallu) = chat_complete(
7395            ChatCtx {
7396                url: &server.uri(),
7397                model: "test-model",
7398                kind: BackendKind::Ollama,
7399                api_key: None,
7400                messages: &messages,
7401                task: "do the thing",
7402                workspace: ".",
7403                color: false,
7404                markdown: false,
7405                tool_offload: false,
7406                spill_store: None,
7407                compaction_store: None,
7408                scratchpad: false,
7409                scratchpad_store: None,
7410                code_search: None,
7411                experience_store: None,
7412                step_ledger: None,
7413                caveats: &caveats,
7414                persona_tools: None,
7415                max_tool_rounds: 2,
7416                narration_nudge_cap: 1,
7417                workflow_grace_rounds: 0,
7418                tool_output_lines: 20,
7419                debug: false,
7420                trace: false,
7421                num_ctx: None,
7422                input_ceiling_pct: 80,
7423                low_budget_pct: 15,
7424                connect_timeout_secs: 5,
7425                inference_timeout_secs: 120,
7426                mid_loop_trim_threshold: 40,
7427                mid_loop_trim_tokens: None,
7428                max_ok_input: None,
7429                build_check_cmd: None,
7430                safe_context: None,
7431                recover_cw_400: None,
7432                note_sink: None,
7433                note_nudge: None,
7434                recall_source: None,
7435                memory_source: None,
7436                summarizer: None,
7437                compress_state: None,
7438                tool_events: None,
7439                phantom_reaches: None,
7440                end_reason: None,
7441                permission_gate: None,
7442                on_round_usage: None,
7443                estimate_ratio: None,
7444                estimation: crate::tokens::TokenEstimation::default(),
7445                summary_input_cap_floor_chars: 8_192,
7446                exec_floor: None,
7447                write_ledger: None,
7448                cancel: None,
7449                git_tool: None,
7450                crew_runner: None,
7451            },
7452            &mut NoMcp,
7453        )
7454        .await
7455        .expect("chat_complete should succeed even when final summary errors");
7456
7457        // Fallback names the limit + the knob — strictly better than the bare
7458        // placeholder.
7459        assert!(reply.contains("tool-call limit"));
7460        assert!(reply.contains("max_tool_rounds"));
7461    }
7462
7463    /// `run_command` called with a tool name as the first word must return a
7464    /// corrective error message, not shell it through agent-bridle.
7465    #[tokio::test]
7466    async fn run_command_refuses_tool_name_as_shell_command() {
7467        let ws = tempfile::TempDir::new().unwrap();
7468        let caveats = Caveats::top();
7469        for tool in [
7470            "list_dir",
7471            "read_file",
7472            "write_file",
7473            "use_skill",
7474            "web_fetch",
7475        ] {
7476            let args = serde_json::json!({ "command": format!("{tool} some/path") });
7477            let out = execute_tool(
7478                "run_command",
7479                &args,
7480                &ws.path().to_string_lossy(),
7481                false,
7482                20,
7483                &caveats,
7484                &mut NoMcp,
7485                None,
7486                None,
7487                None,
7488                None, // memory_source
7489                None,
7490                None,
7491                None, // git_tool
7492                None, // crew_runner
7493                None, // scratchpad_store
7494                None, // code_search
7495                None, // experience_store
7496                None, // step_ledger
7497            )
7498            .await;
7499            assert!(
7500                out.contains("is a tool, not a shell command"),
7501                "expected corrective message for '{tool}', got: {out}"
7502            );
7503        }
7504    }
7505
7506    /// When the final summary 500s, the accumulated usage from the tool rounds
7507    /// must still be returned (not None), so usage.jsonl is not blank.
7508    #[tokio::test]
7509    async fn accumulated_usage_survives_summary_failure() {
7510        let server = MockServer::start().await;
7511        let served = Arc::new(AtomicUsize::new(0));
7512
7513        struct UsageRoundsErrFinal {
7514            served: Arc<AtomicUsize>,
7515        }
7516        impl Respond for UsageRoundsErrFinal {
7517            fn respond(&self, req: &Request) -> ResponseTemplate {
7518                if request_has_tools(req) {
7519                    self.served.fetch_add(1, Ordering::SeqCst);
7520                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
7521                        "message": { "content": "", "tool_calls": [{
7522                            "function": { "name": "definitely_not_a_real_tool", "arguments": {} }
7523                        }]},
7524                        // Ollama reports per-round usage even in non-streaming mode.
7525                        "prompt_eval_count": 100,
7526                        "eval_count": 20,
7527                    }))
7528                } else {
7529                    ResponseTemplate::new(500).set_body_string("boom")
7530                }
7531            }
7532        }
7533
7534        Mock::given(method("POST"))
7535            .and(path("/api/chat"))
7536            .respond_with(UsageRoundsErrFinal {
7537                served: served.clone(),
7538            })
7539            .mount(&server)
7540            .await;
7541
7542        let messages = msgs();
7543        let caveats = Caveats::top();
7544        let cap = 2;
7545        let (reply, _streamed, usage, hallu) = chat_complete(
7546            ChatCtx {
7547                url: &server.uri(),
7548                model: "test-model",
7549                kind: BackendKind::Ollama,
7550                api_key: None,
7551                messages: &messages,
7552                task: "do the thing",
7553                workspace: ".",
7554                color: false,
7555                markdown: false,
7556                tool_offload: false,
7557                spill_store: None,
7558                compaction_store: None,
7559                scratchpad: false,
7560                scratchpad_store: None,
7561                code_search: None,
7562                experience_store: None,
7563                step_ledger: None,
7564                caveats: &caveats,
7565                persona_tools: None,
7566                max_tool_rounds: cap,
7567                narration_nudge_cap: 1,
7568                workflow_grace_rounds: 0,
7569                tool_output_lines: 20,
7570                debug: false,
7571                trace: false,
7572                num_ctx: None,
7573                input_ceiling_pct: 80,
7574                low_budget_pct: 15,
7575                connect_timeout_secs: 5,
7576                inference_timeout_secs: 120,
7577                mid_loop_trim_threshold: 40,
7578                mid_loop_trim_tokens: None,
7579                max_ok_input: None,
7580                build_check_cmd: None,
7581                safe_context: None,
7582                recover_cw_400: None,
7583                note_sink: None,
7584                note_nudge: None,
7585                recall_source: None,
7586                memory_source: None,
7587                summarizer: None,
7588                compress_state: None,
7589                tool_events: None,
7590                phantom_reaches: None,
7591                end_reason: None,
7592                permission_gate: None,
7593                on_round_usage: None,
7594                estimate_ratio: None,
7595                estimation: crate::tokens::TokenEstimation::default(),
7596                summary_input_cap_floor_chars: 8_192,
7597                exec_floor: None,
7598                write_ledger: None,
7599                cancel: None,
7600                git_tool: None,
7601                crew_runner: None,
7602            },
7603            &mut NoMcp,
7604        )
7605        .await
7606        .expect("chat_complete must succeed even when final summary errors");
7607
7608        // The fallback reply must contain accumulated token counts.
7609        assert!(reply.contains("tool-call limit"), "got: {reply}");
7610        assert!(
7611            reply.contains("in / ") && reply.contains("out tokens"),
7612            "fallback must include accumulated token counts, got: {reply}"
7613        );
7614
7615        // The usage returned must be non-None and reflect the rounds.
7616        let u = usage.expect("usage must be Some even when final summary fails");
7617        // SEMANTICS CHANGED in Step 18.1: each round's 100-token prompt
7618        // contained the same history, so the turn input is the largest single
7619        // prompt (100), not the 200 sum that double-counted it.
7620        assert_eq!(
7621            u.input_tokens, 100,
7622            "largest single prompt across 2 rounds, not the sum"
7623        );
7624        assert_eq!(
7625            u.output_tokens, 40,
7626            "2 rounds × 20 output tokens each = 40 total"
7627        );
7628
7629        // Unknown tool calls during cap rounds counted as hallucinations.
7630        assert_eq!(
7631            hallu, cap as u32,
7632            "each round had one hallucinated tool call"
7633        );
7634    }
7635
7636    // -----------------------------------------------------------------------
7637    // Read-only nudge injection test
7638    //
7639    // Scenario: model keeps calling list_dir (read-only) for 3 rounds.
7640    // On round 4 the harness injects the nudge.  The responder detects the
7641    // nudge text in the message list and returns a final text answer instead
7642    // of another tool call, proving the nudge reached the model.
7643    // -----------------------------------------------------------------------
7644
7645    struct ReadOnlyNudgeResponder {
7646        /// Flipped to true the first time the responder sees the nudge text.
7647        nudge_seen: Arc<std::sync::atomic::AtomicBool>,
7648    }
7649
7650    impl Respond for ReadOnlyNudgeResponder {
7651        fn respond(&self, req: &Request) -> ResponseTemplate {
7652            let body = serde_json::from_slice::<serde_json::Value>(&req.body).unwrap_or_default();
7653            let has_nudge = body["messages"]
7654                .as_array()
7655                .map(|msgs| {
7656                    msgs.iter().any(|m| {
7657                        m["content"]
7658                            .as_str()
7659                            .map(|c| c.contains("read-only rounds so far"))
7660                            .unwrap_or(false)
7661                    })
7662                })
7663                .unwrap_or(false);
7664
7665            if has_nudge {
7666                self.nudge_seen
7667                    .store(true, std::sync::atomic::Ordering::SeqCst);
7668                // Return a plain text answer — no more tool calls.
7669                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7670                    "message": { "content": "nudge received, writing file now" }
7671                }))
7672            } else if request_has_tools(req) {
7673                // Keep returning list_dir calls until the nudge arrives.
7674                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7675                    "message": {
7676                        "content": "",
7677                        "tool_calls": [{ "function": {
7678                            "name": "list_dir",
7679                            "arguments": { "path": "." }
7680                        }}]
7681                    }
7682                }))
7683            } else {
7684                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7685                    "message": { "content": "final summary" }
7686                }))
7687            }
7688        }
7689    }
7690
7691    #[tokio::test]
7692    async fn read_only_nudge_injected_after_three_rounds() {
7693        let server = MockServer::start().await;
7694        let nudge_seen = Arc::new(std::sync::atomic::AtomicBool::new(false));
7695
7696        Mock::given(method("POST"))
7697            .and(path("/api/chat"))
7698            .respond_with(ReadOnlyNudgeResponder {
7699                nudge_seen: nudge_seen.clone(),
7700            })
7701            .mount(&server)
7702            .await;
7703
7704        let messages = msgs();
7705        let caveats = Caveats::top();
7706        let (reply, _streamed, _usage, _hallu) = chat_complete(
7707            ChatCtx {
7708                url: &server.uri(),
7709                model: "test-model",
7710                kind: BackendKind::Ollama,
7711                api_key: None,
7712                messages: &messages,
7713                task: "list all files",
7714                workspace: ".",
7715                color: false,
7716                markdown: false,
7717                tool_offload: false,
7718                spill_store: None,
7719                compaction_store: None,
7720                scratchpad: false,
7721                scratchpad_store: None,
7722                code_search: None,
7723                experience_store: None,
7724                step_ledger: None,
7725                caveats: &caveats,
7726                persona_tools: None,
7727                max_tool_rounds: 10,
7728                narration_nudge_cap: 1,
7729                workflow_grace_rounds: 0,
7730                tool_output_lines: 5,
7731                debug: false,
7732                trace: false,
7733                num_ctx: None,
7734                input_ceiling_pct: 80,
7735                low_budget_pct: 15,
7736                connect_timeout_secs: 5,
7737                inference_timeout_secs: 30,
7738                mid_loop_trim_threshold: 40,
7739                mid_loop_trim_tokens: None,
7740                max_ok_input: None,
7741                build_check_cmd: None,
7742                safe_context: None,
7743                recover_cw_400: None,
7744                note_sink: None,
7745                note_nudge: None,
7746                recall_source: None,
7747                memory_source: None,
7748                summarizer: None,
7749                compress_state: None,
7750                tool_events: None,
7751                phantom_reaches: None,
7752                end_reason: None,
7753                permission_gate: None,
7754                on_round_usage: None,
7755                estimate_ratio: None,
7756                estimation: crate::tokens::TokenEstimation::default(),
7757                summary_input_cap_floor_chars: 8_192,
7758                exec_floor: None,
7759                write_ledger: None,
7760                cancel: None,
7761                git_tool: None,
7762                crew_runner: None,
7763            },
7764            &mut NoMcp,
7765        )
7766        .await
7767        .expect("chat_complete should succeed");
7768
7769        assert!(
7770            nudge_seen.load(std::sync::atomic::Ordering::SeqCst),
7771            "nudge was never injected after 3 consecutive read-only rounds"
7772        );
7773        assert_eq!(
7774            reply, "nudge received, writing file now",
7775            "model should have responded to the nudge with a final answer"
7776        );
7777    }
7778}
7779
7780// ---------------------------------------------------------------------------
7781// HTTP-loop tests — streaming, overflow retry, mid-loop trim, and final
7782// summary, all against wiremock backends.
7783// ---------------------------------------------------------------------------
7784
7785#[cfg(test)]
7786mod http_loop_tests {
7787    use super::*;
7788    use crate::caveats::Caveats;
7789    use crate::{BackendKind, MemMessage};
7790    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
7791    use std::sync::Arc;
7792    use wiremock::matchers::{header, method, path};
7793    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
7794
7795    fn msgs() -> Vec<MemMessage> {
7796        vec![
7797            MemMessage::system("you are a test"),
7798            MemMessage::user("do the thing"),
7799        ]
7800    }
7801
7802    fn ctx<'a>(
7803        server_uri: &'a str,
7804        messages: &'a [MemMessage],
7805        caveats: &'a Caveats,
7806    ) -> ChatCtx<'a> {
7807        ChatCtx {
7808            url: server_uri,
7809            model: "test-model",
7810            kind: BackendKind::Ollama,
7811            api_key: None,
7812            messages,
7813            task: "do the thing",
7814            workspace: ".",
7815            color: false,
7816            markdown: false,
7817            tool_offload: false,
7818            spill_store: None,
7819            compaction_store: None,
7820            scratchpad: false,
7821            scratchpad_store: None,
7822            code_search: None,
7823            experience_store: None,
7824            step_ledger: None,
7825            caveats,
7826            persona_tools: None,
7827            max_tool_rounds: 8,
7828            narration_nudge_cap: 1,
7829            workflow_grace_rounds: 0,
7830            tool_output_lines: 20,
7831            debug: false,
7832            trace: false,
7833            num_ctx: None,
7834            input_ceiling_pct: 80,
7835            low_budget_pct: 15,
7836            connect_timeout_secs: 5,
7837            inference_timeout_secs: 30,
7838            mid_loop_trim_threshold: 40,
7839            mid_loop_trim_tokens: None,
7840            max_ok_input: None,
7841            build_check_cmd: None,
7842            safe_context: None,
7843            recover_cw_400: None,
7844            note_sink: None,
7845            note_nudge: None,
7846            recall_source: None,
7847            memory_source: None,
7848            summarizer: None,
7849            compress_state: None,
7850            tool_events: None,
7851            phantom_reaches: None,
7852            end_reason: None,
7853            permission_gate: None,
7854            on_round_usage: None,
7855            estimate_ratio: None,
7856            estimation: crate::tokens::TokenEstimation::default(),
7857            summary_input_cap_floor_chars: 8_192,
7858            exec_floor: None,
7859            write_ledger: None,
7860            cancel: None,
7861            git_tool: None,
7862            crew_runner: None,
7863        }
7864    }
7865
7866    fn body_json(req: &Request) -> serde_json::Value {
7867        serde_json::from_slice(&req.body).unwrap_or_default()
7868    }
7869
7870    fn is_stream(req: &Request) -> bool {
7871        body_json(req)["stream"].as_bool().unwrap_or(false)
7872    }
7873
7874    fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
7875        let body: String = lines
7876            .iter()
7877            .map(|l| format!("{l}\n"))
7878            .collect::<Vec<_>>()
7879            .join("");
7880        ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
7881    }
7882
7883    /// Probe (stream:false) answers with plain content; the streaming re-issue
7884    /// (stream:true) returns NDJSON tokens with usage on the `done` chunk.
7885    struct StreamHappyResponder;
7886    impl Respond for StreamHappyResponder {
7887        fn respond(&self, req: &Request) -> ResponseTemplate {
7888            if is_stream(req) {
7889                ndjson(&[
7890                    serde_json::json!({"message": {"content": "Hello "}, "done": false}),
7891                    serde_json::json!({
7892                        "message": {"content": "world"}, "done": true,
7893                        "prompt_eval_count": 7, "eval_count": 3
7894                    }),
7895                ])
7896            } else {
7897                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7898                    "message": {"content": "probe answer"},
7899                    "prompt_eval_count": 5, "eval_count": 2,
7900                }))
7901            }
7902        }
7903    }
7904
7905    #[tokio::test]
7906    async fn ollama_streams_final_answer_and_merges_usage() {
7907        let server = MockServer::start().await;
7908        Mock::given(method("POST"))
7909            .and(path("/api/chat"))
7910            .respond_with(StreamHappyResponder)
7911            .mount(&server)
7912            .await;
7913
7914        let messages = msgs();
7915        let caveats = Caveats::top();
7916        let (reply, streamed, usage, hallu) =
7917            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
7918                .await
7919                .expect("chat_complete should succeed");
7920
7921        assert_eq!(reply, "Hello world", "tokens accumulated across chunks");
7922        assert!(streamed, "the streaming path printed the tokens");
7923        let u = usage.expect("probe + stream usage merged");
7924        // SEMANTICS CHANGED in Step 18.1: both requests carried the same
7925        // conversation, so input is max(5, 7) = 7 — the old sum (12) counted
7926        // the shared history twice. Output is still 2 + 3 (new generation).
7927        assert_eq!(u.input_tokens, 7, "max(5 probe, 7 stream), not the sum");
7928        assert_eq!(u.output_tokens, 5, "2 (probe) + 3 (stream)");
7929        assert_eq!(hallu, 0);
7930    }
7931
7932    #[test]
7933    fn detects_tools_unsupported_400_phrasings() {
7934        assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7935            "Ollama 400 Bad Request: registry.ollama.ai/library/deepseek-r1:70b does not support tools"
7936        )));
7937        // Looser OpenAI-compatible phrasing.
7938        assert!(is_tools_unsupported_error(&anyhow::anyhow!(
7939            "this model does not support tools at this time"
7940        )));
7941        // Unrelated 400s must NOT trip the no-tools path.
7942        assert!(!is_tools_unsupported_error(&anyhow::anyhow!(
7943            "Ollama 400 Bad Request: context window exceeded"
7944        )));
7945    }
7946
7947    #[test]
7948    fn detects_ollama_tool_xml_parser_errors() {
7949        assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7950            "{}",
7951            r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 7: element \u003cparameter\u003e closed by \u003c/function\u003e"}"#
7952        )));
7953        assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7954            "{}",
7955            r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 2: element <parameter> closed by </function>"}"#
7956        )));
7957        assert!(is_ollama_tool_xml_error(&anyhow::anyhow!(
7958            "{}",
7959            r#"Ollama 500 Internal Server Error: {"error":"XML syntax error on line 3: unexpected end element \u003c/parameter\u003e"}"#
7960        )));
7961        assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7962            "Ollama 500 Internal Server Error: model runner crashed"
7963        )));
7964        assert!(!is_ollama_tool_xml_error(&anyhow::anyhow!(
7965            "OpenAI 400 Bad Request: XML syntax error in user supplied file"
7966        )));
7967    }
7968
7969    /// A model that rejects the `tools` field (deepseek-r1) 400s on the first
7970    /// dispatch; newt must drop tools and re-dispatch, answering normally. The
7971    /// tools-absent retry is the one that succeeds — no tools-400 loop.
7972    struct NoToolsResponder {
7973        rejections: Arc<AtomicUsize>,
7974        served_without_tools: Arc<AtomicBool>,
7975    }
7976    impl Respond for NoToolsResponder {
7977        fn respond(&self, req: &Request) -> ResponseTemplate {
7978            let has_tools = body_json(req).get("tools").is_some();
7979            if has_tools {
7980                self.rejections.fetch_add(1, Ordering::SeqCst);
7981                return ResponseTemplate::new(400).set_body_string(
7982                    "registry.ollama.ai/library/deepseek-r1:70b does not support tools",
7983                );
7984            }
7985            self.served_without_tools.store(true, Ordering::SeqCst);
7986            if is_stream(req) {
7987                ndjson(&[serde_json::json!({
7988                    "message": {"content": "hello there"}, "done": true,
7989                    "prompt_eval_count": 4, "eval_count": 2
7990                })])
7991            } else {
7992                ResponseTemplate::new(200).set_body_json(serde_json::json!({
7993                    "message": {"content": "probe answer"},
7994                    "prompt_eval_count": 4, "eval_count": 2,
7995                }))
7996            }
7997        }
7998    }
7999
8000    #[tokio::test]
8001    async fn no_tools_model_recovers_by_dropping_tools() {
8002        let server = MockServer::start().await;
8003        let rejections = Arc::new(AtomicUsize::new(0));
8004        let served_without_tools = Arc::new(AtomicBool::new(false));
8005        Mock::given(method("POST"))
8006            .and(path("/api/chat"))
8007            .respond_with(NoToolsResponder {
8008                rejections: rejections.clone(),
8009                served_without_tools: served_without_tools.clone(),
8010            })
8011            .mount(&server)
8012            .await;
8013
8014        let messages = msgs();
8015        let caveats = Caveats::top();
8016        let (reply, streamed, _usage, _) =
8017            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8018                .await
8019                .expect("a no-tools model still answers a bare prompt");
8020
8021        assert_eq!(reply, "hello there", "the tools-absent retry answered");
8022        assert!(streamed);
8023        assert!(
8024            served_without_tools.load(Ordering::SeqCst),
8025            "a request without the tools field was eventually served"
8026        );
8027        assert_eq!(
8028            rejections.load(Ordering::SeqCst),
8029            1,
8030            "exactly one tools-bearing request 400s — the drop is self-limiting"
8031        );
8032    }
8033
8034    /// Ollama can 500 before returning assistant content when its XML parser
8035    /// sees malformed Qwen-style tool-call tags. That is not the same as
8036    /// "model does not support tools": Newt should retry with tools still
8037    /// advertised so the model can make forward progress on the next round.
8038    struct MalformedToolXmlResponder {
8039        rejections: Arc<AtomicUsize>,
8040        served_with_tools_after_error: Arc<AtomicBool>,
8041        served_without_tools: Arc<AtomicBool>,
8042    }
8043    impl Respond for MalformedToolXmlResponder {
8044        fn respond(&self, req: &Request) -> ResponseTemplate {
8045            if body_json(req).get("tools").is_some() {
8046                if self.rejections.fetch_add(1, Ordering::SeqCst) == 0 {
8047                    return ResponseTemplate::new(500).set_body_json(serde_json::json!({
8048                        "error": "XML syntax error on line 7: element <parameter> closed by </function>"
8049                    }));
8050                }
8051                self.served_with_tools_after_error
8052                    .store(true, Ordering::SeqCst);
8053                if is_stream(req) {
8054                    return ndjson(&[serde_json::json!({
8055                        "message": {"content": "recovered with tools still available"},
8056                        "done": true,
8057                        "prompt_eval_count": 4,
8058                        "eval_count": 3
8059                    })]);
8060                }
8061                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
8062                    "message": {"content": "probe answer with tools still available"},
8063                    "prompt_eval_count": 4,
8064                    "eval_count": 3,
8065                }));
8066            }
8067            self.served_without_tools.store(true, Ordering::SeqCst);
8068            if is_stream(req) {
8069                ndjson(&[serde_json::json!({
8070                    "message": {"content": "unexpected no-tools stream"},
8071                    "done": true,
8072                    "prompt_eval_count": 4, "eval_count": 3
8073                })])
8074            } else {
8075                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8076                    "message": {"content": "unexpected no-tools probe"},
8077                    "prompt_eval_count": 4, "eval_count": 3,
8078                }))
8079            }
8080        }
8081    }
8082
8083    #[tokio::test]
8084    async fn ollama_tool_xml_error_recovers_with_tools_still_available() {
8085        let server = MockServer::start().await;
8086        let rejections = Arc::new(AtomicUsize::new(0));
8087        let served_with_tools_after_error = Arc::new(AtomicBool::new(false));
8088        let served_without_tools = Arc::new(AtomicBool::new(false));
8089        Mock::given(method("POST"))
8090            .and(path("/api/chat"))
8091            .respond_with(MalformedToolXmlResponder {
8092                rejections: rejections.clone(),
8093                served_with_tools_after_error: served_with_tools_after_error.clone(),
8094                served_without_tools: served_without_tools.clone(),
8095            })
8096            .mount(&server)
8097            .await;
8098
8099        let messages = msgs();
8100        let caveats = Caveats::top();
8101        let (reply, streamed, _usage, _) =
8102            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8103                .await
8104                .expect("malformed XML tool-call parser errors should retry with tools");
8105
8106        assert_eq!(reply, "recovered with tools still available");
8107        assert!(streamed);
8108        assert!(
8109            served_with_tools_after_error.load(Ordering::SeqCst),
8110            "a tools-bearing request was served after the XML parser failure"
8111        );
8112        assert!(
8113            !served_without_tools.load(Ordering::SeqCst),
8114            "malformed XML must not disable tools for the turn"
8115        );
8116        assert_eq!(
8117            rejections.load(Ordering::SeqCst),
8118            3,
8119            "the XML error probe, retry probe, and streaming re-issue all keep tools advertised"
8120        );
8121    }
8122
8123    /// The streaming re-issue produces no tokens — the loop must fall back to
8124    /// the probe round's content rather than returning silence.
8125    struct EmptyStreamResponder;
8126    impl Respond for EmptyStreamResponder {
8127        fn respond(&self, req: &Request) -> ResponseTemplate {
8128            if is_stream(req) {
8129                ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
8130            } else {
8131                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8132                    "message": {"content": "probe says hi"},
8133                    "prompt_eval_count": 5, "eval_count": 2,
8134                }))
8135            }
8136        }
8137    }
8138
8139    #[tokio::test]
8140    async fn empty_stream_falls_back_to_probe_content() {
8141        let server = MockServer::start().await;
8142        Mock::given(method("POST"))
8143            .and(path("/api/chat"))
8144            .respond_with(EmptyStreamResponder)
8145            .mount(&server)
8146            .await;
8147
8148        let messages = msgs();
8149        let caveats = Caveats::top();
8150        let (reply, streamed, usage, _) =
8151            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8152                .await
8153                .expect("chat_complete should succeed");
8154
8155        assert_eq!(reply, "probe says hi");
8156        assert!(!streamed, "fallback content was never streamed");
8157        assert_eq!(usage.unwrap().input_tokens, 5);
8158    }
8159
8160    /// Regression for the DGX wedge: the non-streamed probe said "Let me verify
8161    /// by looking...", then the streaming re-issue returned no tokens. The probe
8162    /// fallback must still go through the no-tool nudge gate instead of ending
8163    /// the turn and forcing the operator to type "continue".
8164    struct EmptyStreamPendingActionResponder {
8165        probes: Arc<AtomicUsize>,
8166    }
8167    impl Respond for EmptyStreamPendingActionResponder {
8168        fn respond(&self, req: &Request) -> ResponseTemplate {
8169            if is_stream(req) {
8170                return ndjson(&[serde_json::json!({
8171                    "message": {"content": ""},
8172                    "done": true,
8173                    "prompt_eval_count": 6,
8174                    "eval_count": 0
8175                })]);
8176            }
8177            let probe = self.probes.fetch_add(1, Ordering::SeqCst);
8178            let content = if probe == 0 {
8179                "Now I understand the issue. Let me verify by looking at format_rollup_detail."
8180            } else {
8181                "Verified after the automatic continue."
8182            };
8183            ResponseTemplate::new(200).set_body_json(serde_json::json!({
8184                "message": {"content": content},
8185                "prompt_eval_count": 5 + probe as u32,
8186                "eval_count": 2,
8187            }))
8188        }
8189    }
8190
8191    #[tokio::test]
8192    async fn empty_stream_probe_fallback_pending_action_nudges_and_continues() {
8193        let server = MockServer::start().await;
8194        let probes = Arc::new(AtomicUsize::new(0));
8195        Mock::given(method("POST"))
8196            .and(path("/api/chat"))
8197            .respond_with(EmptyStreamPendingActionResponder {
8198                probes: probes.clone(),
8199            })
8200            .mount(&server)
8201            .await;
8202
8203        let messages = msgs();
8204        let caveats = Caveats::top();
8205        let (reply, streamed, _usage, _) =
8206            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8207                .await
8208                .expect("chat_complete should auto-continue after pending probe fallback");
8209
8210        assert_eq!(
8211            probes.load(Ordering::SeqCst),
8212            2,
8213            "the nudge ran a second probe"
8214        );
8215        assert_eq!(reply, "Verified after the automatic continue.");
8216        assert!(!streamed, "the second answer also came from probe fallback");
8217        assert!(
8218            !reply.contains("Let me verify"),
8219            "must not return the pending-action narration"
8220        );
8221    }
8222
8223    /// Probe AND stream both empty, with no safe-context hint → the loop gives
8224    /// the explicit empty-response diagnostic instead of silence.
8225    struct AllEmptyResponder;
8226    impl Respond for AllEmptyResponder {
8227        fn respond(&self, req: &Request) -> ResponseTemplate {
8228            if is_stream(req) {
8229                ndjson(&[serde_json::json!({"message": {"content": ""}, "done": true})])
8230            } else {
8231                ResponseTemplate::new(200)
8232                    .set_body_json(serde_json::json!({"message": {"content": ""}}))
8233            }
8234        }
8235    }
8236
8237    #[tokio::test]
8238    async fn fully_empty_response_yields_diagnostic_message() {
8239        let server = MockServer::start().await;
8240        Mock::given(method("POST"))
8241            .and(path("/api/chat"))
8242            .respond_with(AllEmptyResponder)
8243            .mount(&server)
8244            .await;
8245
8246        let messages = msgs();
8247        let caveats = Caveats::top();
8248        let (reply, streamed, _, _) =
8249            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8250                .await
8251                .expect("chat_complete should succeed");
8252
8253        assert!(
8254            reply.contains("model returned an empty response"),
8255            "got: {reply}"
8256        );
8257        assert!(reply.contains("newt doctor"), "points at diagnostics");
8258        assert!(!streamed);
8259    }
8260
8261    struct SuspiciousEmptyThenRecover {
8262        probes: Arc<AtomicUsize>,
8263        saw_nudge: Arc<AtomicBool>,
8264    }
8265    impl Respond for SuspiciousEmptyThenRecover {
8266        fn respond(&self, req: &Request) -> ResponseTemplate {
8267            if is_stream(req) {
8268                if self.probes.load(Ordering::SeqCst) <= 1 {
8269                    ndjson(&[serde_json::json!({
8270                        "message": {"content": ""},
8271                        "done": true,
8272                        "prompt_eval_count": 9,
8273                        "eval_count": 4
8274                    })])
8275                } else {
8276                    ndjson(&[
8277                        serde_json::json!({"message": {"content": "recovered "}, "done": false}),
8278                        serde_json::json!({
8279                            "message": {"content": "after empty retry"},
8280                            "done": true,
8281                            "prompt_eval_count": 5,
8282                            "eval_count": 3
8283                        }),
8284                    ])
8285                }
8286            } else {
8287                let body = body_json(req);
8288                if body["messages"].as_array().into_iter().flatten().any(|m| {
8289                    m["content"]
8290                        .as_str()
8291                        .unwrap_or("")
8292                        .contains("no assistant-visible content")
8293                }) {
8294                    self.saw_nudge.store(true, Ordering::SeqCst);
8295                }
8296                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8297                if n == 1 {
8298                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8299                        "message": {
8300                            "content": "",
8301                            "thinking": "I know what to do but did not emit final text."
8302                        },
8303                        "prompt_eval_count": 10,
8304                        "eval_count": 2559,
8305                    }))
8306                } else {
8307                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8308                        "message": {"content": "recovered after empty retry"},
8309                        "prompt_eval_count": 5,
8310                        "eval_count": 3,
8311                    }))
8312                }
8313            }
8314        }
8315    }
8316
8317    #[tokio::test]
8318    async fn suspicious_empty_generated_output_retries_with_nudge() {
8319        let server = MockServer::start().await;
8320        let probes = Arc::new(AtomicUsize::new(0));
8321        let saw_nudge = Arc::new(AtomicBool::new(false));
8322        Mock::given(method("POST"))
8323            .and(path("/api/chat"))
8324            .respond_with(SuspiciousEmptyThenRecover {
8325                probes: probes.clone(),
8326                saw_nudge: saw_nudge.clone(),
8327            })
8328            .mount(&server)
8329            .await;
8330
8331        let messages = msgs();
8332        let caveats = Caveats::top();
8333        let (reply, streamed, usage, _) =
8334            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8335                .await
8336                .expect("chat_complete should succeed");
8337
8338        assert_eq!(reply, "recovered after empty retry");
8339        assert!(streamed);
8340        assert_eq!(probes.load(Ordering::SeqCst), 2);
8341        assert!(saw_nudge.load(Ordering::SeqCst));
8342        assert!(
8343            usage
8344                .expect("usage survives suspicious retry")
8345                .output_tokens
8346                >= 2566,
8347            "usage from the suspicious empty round must be preserved"
8348        );
8349    }
8350
8351    struct SuspiciousEmptyTwiceThenRecover {
8352        probes: Arc<AtomicUsize>,
8353        saw_strong_nudge: Arc<AtomicBool>,
8354    }
8355    impl Respond for SuspiciousEmptyTwiceThenRecover {
8356        fn respond(&self, req: &Request) -> ResponseTemplate {
8357            if is_stream(req) {
8358                if self.probes.load(Ordering::SeqCst) <= 2 {
8359                    ndjson(&[serde_json::json!({
8360                        "message": {"content": ""},
8361                        "done": true,
8362                        "prompt_eval_count": 9,
8363                        "eval_count": 4
8364                    })])
8365                } else {
8366                    ndjson(&[serde_json::json!({
8367                        "message": {"content": "recovered after strong hidden-only nudge"},
8368                        "done": true,
8369                        "prompt_eval_count": 5,
8370                        "eval_count": 3
8371                    })])
8372                }
8373            } else {
8374                let body = body_json(req);
8375                if body["messages"].as_array().into_iter().flatten().any(|m| {
8376                    m["content"]
8377                        .as_str()
8378                        .unwrap_or("")
8379                        .contains("Hidden thinking is not an action")
8380                }) {
8381                    self.saw_strong_nudge.store(true, Ordering::SeqCst);
8382                }
8383                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8384                if n <= 2 {
8385                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8386                        "message": {
8387                            "content": "",
8388                            "thinking": "I know the next action but did not emit it."
8389                        },
8390                        "prompt_eval_count": 10,
8391                        "eval_count": 2559,
8392                    }))
8393                } else {
8394                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8395                        "message": {"content": "recovered after strong hidden-only nudge"},
8396                        "prompt_eval_count": 5,
8397                        "eval_count": 3,
8398                    }))
8399                }
8400            }
8401        }
8402    }
8403
8404    #[tokio::test]
8405    async fn repeated_thinking_only_gets_stronger_second_nudge() {
8406        let server = MockServer::start().await;
8407        let probes = Arc::new(AtomicUsize::new(0));
8408        let saw_strong_nudge = Arc::new(AtomicBool::new(false));
8409        Mock::given(method("POST"))
8410            .and(path("/api/chat"))
8411            .respond_with(SuspiciousEmptyTwiceThenRecover {
8412                probes: probes.clone(),
8413                saw_strong_nudge: saw_strong_nudge.clone(),
8414            })
8415            .mount(&server)
8416            .await;
8417
8418        let messages = msgs();
8419        let caveats = Caveats::top();
8420        let (reply, streamed, _, _) =
8421            chat_complete(ctx(&server.uri(), &messages, &caveats), &mut NoMcp)
8422                .await
8423                .expect("second hidden-only nudge should recover the turn");
8424
8425        assert_eq!(reply, "recovered after strong hidden-only nudge");
8426        assert!(streamed);
8427        assert_eq!(probes.load(Ordering::SeqCst), 3);
8428        assert!(saw_strong_nudge.load(Ordering::SeqCst));
8429    }
8430
8431    struct SuspiciousEmptyStaysEmpty;
8432    impl Respond for SuspiciousEmptyStaysEmpty {
8433        fn respond(&self, req: &Request) -> ResponseTemplate {
8434            if is_stream(req) {
8435                ndjson(&[serde_json::json!({
8436                    "message": {"content": ""},
8437                    "done": true,
8438                    "prompt_eval_count": 9,
8439                    "eval_count": 4
8440                })])
8441            } else {
8442                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8443                    "message": {
8444                        "content": "",
8445                        "reasoning_content": "internal-only response"
8446                    },
8447                    "prompt_eval_count": 10,
8448                    "eval_count": 12,
8449                }))
8450            }
8451        }
8452    }
8453
8454    #[tokio::test]
8455    async fn suspicious_empty_generated_output_reports_targeted_diagnostic() {
8456        let server = MockServer::start().await;
8457        Mock::given(method("POST"))
8458            .and(path("/api/chat"))
8459            .respond_with(SuspiciousEmptyStaysEmpty)
8460            .mount(&server)
8461            .await;
8462
8463        let messages = msgs();
8464        let caveats = Caveats::top();
8465        let uri = server.uri();
8466        let mut c = ctx(&uri, &messages, &caveats);
8467        c.trace = true;
8468        let (reply, streamed, _, _) = chat_complete(c, &mut NoMcp)
8469            .await
8470            .expect("chat_complete should succeed");
8471
8472        assert!(reply.contains("generated output tokens"), "got: {reply}");
8473        assert!(
8474            reply.contains("reasoning_content"),
8475            "diagnostic should name the non-content field: {reply}"
8476        );
8477        assert!(reply.contains("--trace"), "points at trace diagnostics");
8478        assert!(!streamed);
8479    }
8480
8481    /// First round: empty content with token usage near the safe-context
8482    /// ceiling → the loop must emit the overflow notice, trim, and retry.
8483    /// Second round: a real answer.
8484    struct OverflowThenRecover {
8485        probes: Arc<AtomicUsize>,
8486    }
8487    impl Respond for OverflowThenRecover {
8488        fn respond(&self, req: &Request) -> ResponseTemplate {
8489            if is_stream(req) {
8490                // Streams mirror the probe sequence: empty first, content after.
8491                if self.probes.load(Ordering::SeqCst) <= 1 {
8492                    ndjson(&[serde_json::json!({
8493                        "message": {"content": ""}, "done": true,
8494                        "prompt_eval_count": 90, "eval_count": 1
8495                    })])
8496                } else {
8497                    ndjson(&[
8498                        serde_json::json!({"message": {"content": "recovered "}, "done": false}),
8499                        serde_json::json!({
8500                            "message": {"content": "after trim"}, "done": true,
8501                            "prompt_eval_count": 12, "eval_count": 4
8502                        }),
8503                    ])
8504                }
8505            } else {
8506                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
8507                if n == 1 {
8508                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8509                        "message": {"content": ""},
8510                        "prompt_eval_count": 90, "eval_count": 1,
8511                    }))
8512                } else {
8513                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8514                        "message": {"content": "recovered after trim"},
8515                        "prompt_eval_count": 12, "eval_count": 4,
8516                    }))
8517                }
8518            }
8519        }
8520    }
8521
8522    #[tokio::test]
8523    async fn context_overflow_trims_and_retries_then_recovers() {
8524        let server = MockServer::start().await;
8525        let probes = Arc::new(AtomicUsize::new(0));
8526        Mock::given(method("POST"))
8527            .and(path("/api/chat"))
8528            .respond_with(OverflowThenRecover {
8529                probes: probes.clone(),
8530            })
8531            .mount(&server)
8532            .await;
8533
8534        let messages = msgs();
8535        let caveats = Caveats::top();
8536        let uri = server.uri();
8537        let mut c = ctx(&uri, &messages, &caveats);
8538        // Safe window of 100 input tokens: the empty round reported a 90-token
8539        // prompt, and 90 ≥ 85% of 100, so it is classified as likely overflow.
8540        // (Step 18.1: the check compares the largest single prompt against the
8541        // window — the old multi-round sum, 180 here, inflated past 85% after
8542        // two rounds on EVERY long turn, firing spurious overflow retries.)
8543        c.safe_context = Some(100);
8544        let (reply, streamed, usage, _) = chat_complete(c, &mut NoMcp)
8545            .await
8546            .expect("chat_complete should succeed");
8547
8548        assert_eq!(
8549            probes.load(Ordering::SeqCst),
8550            2,
8551            "overflow must trigger exactly one trim-and-retry probe"
8552        );
8553        assert_eq!(reply, "recovered after trim");
8554        assert!(streamed);
8555        assert_eq!(
8556            usage
8557                .expect("accumulated usage survives the retry")
8558                .input_tokens,
8559            90,
8560            "largest single prompt across the overflowed + recovered rounds"
8561        );
8562    }
8563
8564    /// Tool calls every round with a tiny trim threshold: the mid-loop
8565    /// compression must fire — observable as the compaction marker (NOT the
8566    /// old amputation placeholder) reaching the model. With no summarizer
8567    /// injected, this is the static-fallback path (Step 18.4).
8568    struct TrimObservingResponder {
8569        marker_seen: Arc<AtomicBool>,
8570        old_placeholder_seen: Arc<AtomicBool>,
8571    }
8572    impl Respond for TrimObservingResponder {
8573        fn respond(&self, req: &Request) -> ResponseTemplate {
8574            let body = body_json(req);
8575            let contains = |needle: &str| {
8576                body["messages"]
8577                    .as_array()
8578                    .map(|m| {
8579                        m.iter().any(|msg| {
8580                            msg["content"]
8581                                .as_str()
8582                                .map(|c| c.contains(needle))
8583                                .unwrap_or(false)
8584                        })
8585                    })
8586                    .unwrap_or(false)
8587            };
8588            if contains(SUMMARY_PREFIX) && contains("Summary generation was unavailable.") {
8589                self.marker_seen.store(true, Ordering::SeqCst);
8590            }
8591            if contains("earlier tool-call messages omitted") {
8592                self.old_placeholder_seen.store(true, Ordering::SeqCst);
8593            }
8594            if body.get("tools").is_some() {
8595                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8596                    "message": {"content": "", "tool_calls": [{
8597                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8598                    }]}
8599                }))
8600            } else {
8601                ResponseTemplate::new(200)
8602                    .set_body_json(serde_json::json!({"message": {"content": "final after trim"}}))
8603            }
8604        }
8605    }
8606
8607    #[tokio::test]
8608    async fn mid_loop_compression_fires_when_message_list_grows() {
8609        let server = MockServer::start().await;
8610        let marker_seen = Arc::new(AtomicBool::new(false));
8611        let old_placeholder_seen = Arc::new(AtomicBool::new(false));
8612        Mock::given(method("POST"))
8613            .and(path("/api/chat"))
8614            .respond_with(TrimObservingResponder {
8615                marker_seen: marker_seen.clone(),
8616                old_placeholder_seen: old_placeholder_seen.clone(),
8617            })
8618            .mount(&server)
8619            .await;
8620
8621        let messages = msgs();
8622        let caveats = Caveats::top();
8623        let uri = server.uri();
8624        let mut c = ctx(&uri, &messages, &caveats);
8625        c.max_tool_rounds = 3;
8626        c.mid_loop_trim_threshold = 4;
8627        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8628            .await
8629            .expect("chat_complete should succeed");
8630
8631        assert!(
8632            marker_seen.load(Ordering::SeqCst),
8633            "the static compaction marker must have reached the model mid-loop"
8634        );
8635        assert!(
8636            !old_placeholder_seen.load(Ordering::SeqCst),
8637            "the pre-18.4 amputation placeholder must never be emitted"
8638        );
8639        assert_eq!(reply, "final after trim");
8640    }
8641
8642    /// The cap-exit summary round returns 200 with EMPTY content: the loop
8643    /// must surface the named fallback, not the empty string.
8644    struct EmptyFinalSummary;
8645    impl Respond for EmptyFinalSummary {
8646        fn respond(&self, req: &Request) -> ResponseTemplate {
8647            if body_json(req).get("tools").is_some() {
8648                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8649                    "message": {"content": "", "tool_calls": [{
8650                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8651                    }]}
8652                }))
8653            } else {
8654                ResponseTemplate::new(200)
8655                    .set_body_json(serde_json::json!({"message": {"content": ""}}))
8656            }
8657        }
8658    }
8659
8660    #[tokio::test]
8661    async fn empty_final_summary_yields_cap_fallback() {
8662        let server = MockServer::start().await;
8663        Mock::given(method("POST"))
8664            .and(path("/api/chat"))
8665            .respond_with(EmptyFinalSummary)
8666            .mount(&server)
8667            .await;
8668
8669        let messages = msgs();
8670        let caveats = Caveats::top();
8671        let uri = server.uri();
8672        let mut c = ctx(&uri, &messages, &caveats);
8673        c.max_tool_rounds = 2;
8674        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8675            .await
8676            .expect("chat_complete should succeed");
8677
8678        assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
8679        assert!(reply.contains("max_tool_rounds"), "names the knob");
8680    }
8681
8682    /// #867 regression: the cap-exit summary cites a file that does not
8683    /// exist (the forensic transcript's exact shape — evidence trimmed, the
8684    /// model reconstructs a plausible path). The claim check must append a
8685    /// visible refutation naming the path, while leaving the model's prose
8686    /// intact as a prefix.
8687    struct HallucinatingFinalSummary;
8688    impl Respond for HallucinatingFinalSummary {
8689        fn respond(&self, req: &Request) -> ResponseTemplate {
8690            if body_json(req).get("tools").is_some() {
8691                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8692                    "message": {"content": "", "tool_calls": [{
8693                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
8694                    }]}
8695                }))
8696            } else {
8697                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8698                    "message": {"content":
8699                        "The /end command is defined in newt-tui/src/commands.rs \
8700                         (lines 38-40) as enum variants."}
8701                }))
8702            }
8703        }
8704    }
8705
8706    #[tokio::test]
8707    async fn cap_exit_hallucinated_path_gets_claim_check_refutation() {
8708        let server = MockServer::start().await;
8709        Mock::given(method("POST"))
8710            .and(path("/api/chat"))
8711            .respond_with(HallucinatingFinalSummary)
8712            .mount(&server)
8713            .await;
8714
8715        let messages = msgs();
8716        let caveats = Caveats::top();
8717        let uri = server.uri();
8718        let mut c = ctx(&uri, &messages, &caveats);
8719        c.max_tool_rounds = 2;
8720        // `ctx` sets workspace = "." (this crate's dir under cargo test), so
8721        // the cited `newt-tui/src/commands.rs` provably does not exist there.
8722        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
8723            .await
8724            .expect("chat_complete should succeed");
8725
8726        assert!(
8727            reply.contains("newt-tui/src/commands.rs (lines 38-40)"),
8728            "the model's prose is preserved verbatim: {reply}"
8729        );
8730        assert!(reply.contains("⚠ claim check (#867)"), "got: {reply}");
8731        assert!(
8732            reply.contains("`newt-tui/src/commands.rs`"),
8733            "the fabricated path is named in the refutation: {reply}"
8734        );
8735    }
8736
8737    // -----------------------------------------------------------------------
8738    // OpenAI-path coverage
8739    // -----------------------------------------------------------------------
8740
8741    #[tokio::test]
8742    async fn chat_complete_dispatches_openai_kind_and_returns_first_round_answer() {
8743        let server = MockServer::start().await;
8744        Mock::given(method("POST"))
8745            .and(path("/v1/chat/completions"))
8746            .and(header("authorization", "Bearer sk-test"))
8747            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8748                "choices": [{"message": {"content": "openai says hi"}}],
8749                "usage": {"prompt_tokens": 10, "completion_tokens": 4},
8750            })))
8751            .mount(&server)
8752            .await;
8753
8754        let messages = msgs();
8755        let caveats = Caveats::top();
8756        let uri = server.uri();
8757        let mut c = ctx(&uri, &messages, &caveats);
8758        c.kind = BackendKind::Openai;
8759        c.api_key = Some("sk-test");
8760        // Calling chat_complete (not openai_chat_complete) pins the dispatch.
8761        let (reply, streamed, usage, hallu) = chat_complete(c, &mut NoMcp)
8762            .await
8763            .expect("openai dispatch should succeed");
8764
8765        assert_eq!(reply, "openai says hi");
8766        assert!(!streamed, "openai path is non-streaming");
8767        let u = usage.unwrap();
8768        assert_eq!((u.input_tokens, u.output_tokens), (10, 4));
8769        assert_eq!(hallu, 0);
8770    }
8771
8772    #[tokio::test]
8773    async fn openai_strips_inline_think_and_never_returns_reasoning_content() {
8774        // #857: a reasoning model served with the parser OFF puts its CoT inline as
8775        // <think>…</think> in content; served with the parser ON it lands in a
8776        // separate reasoning_content field. Either way the returned answer must be
8777        // ONLY the clean content — no <think> markers, no CoT, no reasoning_content.
8778        let server = MockServer::start().await;
8779        Mock::given(method("POST"))
8780            .and(path("/v1/chat/completions"))
8781            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8782                "choices": [{"message": {
8783                    "content": "<think>secret chain of thought</think>The final answer.",
8784                    "reasoning_content": "separate-channel reasoning"
8785                }}]
8786            })))
8787            .mount(&server)
8788            .await;
8789
8790        let messages = msgs();
8791        let caveats = Caveats::top();
8792        let uri = server.uri();
8793        let mut c = ctx(&uri, &messages, &caveats);
8794        c.kind = BackendKind::Openai;
8795        let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
8796            .await
8797            .expect("openai dispatch should succeed");
8798
8799        assert_eq!(reply, "The final answer.", "answer is the stripped content");
8800        assert!(!reply.contains("<think>"), "no think markers: {reply}");
8801        assert!(
8802            !reply.contains("secret chain of thought"),
8803            "inline CoT must not leak: {reply}"
8804        );
8805        assert!(
8806            !reply.contains("separate-channel reasoning"),
8807            "reasoning_content must not leak into the reply: {reply}"
8808        );
8809    }
8810
8811    #[tokio::test]
8812    async fn openai_empty_content_yields_diagnostic_message() {
8813        let server = MockServer::start().await;
8814        Mock::given(method("POST"))
8815            .and(path("/v1/chat/completions"))
8816            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
8817                "choices": [{"message": {"content": ""}}]
8818            })))
8819            .mount(&server)
8820            .await;
8821
8822        let messages = msgs();
8823        let caveats = Caveats::top();
8824        let uri = server.uri();
8825        let mut c = ctx(&uri, &messages, &caveats);
8826        c.kind = BackendKind::Openai;
8827        let (reply, _, _, _) = chat_complete(c, &mut NoMcp).await.expect("should succeed");
8828        assert!(
8829            reply.contains("model returned an empty response"),
8830            "got: {reply}"
8831        );
8832    }
8833
8834    /// Mock MCP that handles exactly one namespaced tool for routing tests.
8835    struct OneToolMcp {
8836        name: &'static str,
8837        result: &'static str,
8838    }
8839    #[async_trait::async_trait]
8840    impl McpTools for OneToolMcp {
8841        fn handles(&self, name: &str) -> bool {
8842            name == self.name
8843        }
8844        fn tool_defs(&self) -> Vec<serde_json::Value> {
8845            Vec::new()
8846        }
8847        async fn call(&mut self, _name: &str, _args: &serde_json::Value) -> String {
8848            self.result.to_string()
8849        }
8850    }
8851
8852    /// An API proxy may put Anthropic-native tool-use blocks
8853    /// (`{"name":"…","input":{}}`) inside the OpenAI `tool_calls` array
8854    /// instead of converting them to `{"function":{"name":"…","arguments":"…"}}`.
8855    /// The loop must detect the missing `function` key, fall back to the
8856    /// Anthropic-native fields, and route the call correctly.
8857    #[tokio::test]
8858    async fn openai_anthropic_native_tool_calls_route_correctly() {
8859        let server = MockServer::start().await;
8860        // Round 1: Anthropic-native tool-use block in the tool_calls array.
8861        // Round 2: plain text final answer after receiving the tool result.
8862        let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8863        let cc = call_count.clone();
8864        Mock::given(method("POST"))
8865            .and(path("/v1/chat/completions"))
8866            .respond_with(move |_req: &Request| {
8867                let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8868                if n == 0 {
8869                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8870                        "choices": [{"message": {
8871                            "content": null,
8872                            "tool_calls": [{
8873                                "type": "tool_use",
8874                                "id": "toolu_01ABC",
8875                                "name": "my_server__my_tool",
8876                                "input": {"key": "value"}
8877                            }]
8878                        }}],
8879                        "usage": {"prompt_tokens": 50, "completion_tokens": 10}
8880                    }))
8881                } else {
8882                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8883                        "choices": [{"message": {"content": "done after anthropic-native tool"}}],
8884                        "usage": {"prompt_tokens": 60, "completion_tokens": 8}
8885                    }))
8886                }
8887            })
8888            .mount(&server)
8889            .await;
8890
8891        let messages = msgs();
8892        let caveats = Caveats::top();
8893        let uri = server.uri();
8894        let mut c = ctx(&uri, &messages, &caveats);
8895        c.kind = BackendKind::Openai;
8896        let mut mcp = OneToolMcp {
8897            name: "my_server__my_tool",
8898            result: "tool-result-text",
8899        };
8900        let (reply, _, _, hallu) = chat_complete(c, &mut mcp)
8901            .await
8902            .expect("should succeed with anthropic-native tool format");
8903        assert_eq!(reply, "done after anthropic-native tool");
8904        assert_eq!(hallu, 0, "should not be counted as hallucination");
8905        assert_eq!(
8906            call_count.load(std::sync::atomic::Ordering::SeqCst),
8907            2,
8908            "must have done both rounds (tool call + final answer)"
8909        );
8910    }
8911
8912    /// Regression: some OpenAI-compatible API proxies normalise hyphens to
8913    /// underscores in tool names (`acme-server` → `acme_server`).  Verify that
8914    /// the underscore form routes through MCP rather than falling to "unknown tool".
8915    #[tokio::test]
8916    async fn openai_hyphenated_server_name_routes_through_mcp() {
8917        let server = MockServer::start().await;
8918        let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
8919        let cc = call_count.clone();
8920        Mock::given(method("POST"))
8921            .and(path("/v1/chat/completions"))
8922            .respond_with(move |_req: &Request| {
8923                let n = cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
8924                if n == 0 {
8925                    // Proxy returns the underscore-normalised form of the
8926                    // server prefix even though we advertised hyphens.
8927                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8928                        "choices": [{"message": {
8929                            "content": "",
8930                            "tool_calls": [{
8931                                "index": 0,
8932                                "function": {
8933                                    "arguments": "{}",
8934                                    "name": "acme_server__probe_tool"
8935                                },
8936                                "id": "call_probe_01",
8937                                "type": "function"
8938                            }]
8939                        }}],
8940                        "usage": {"prompt_tokens": 599, "completion_tokens": 30}
8941                    }))
8942                } else {
8943                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
8944                        "choices": [{"message": {"content": "outlook routed correctly"}}]
8945                    }))
8946                }
8947            })
8948            .mount(&server)
8949            .await;
8950
8951        let messages = msgs();
8952        let caveats = Caveats::top();
8953        let uri = server.uri();
8954        let mut c = ctx(&uri, &messages, &caveats);
8955        c.kind = BackendKind::Openai;
8956        // OneToolMcp.handles() must match the underscore form the proxy returns.
8957        let mut mcp = OneToolMcp {
8958            name: "acme_server__probe_tool",
8959            result: "ok",
8960        };
8961        let (reply, _, _, _) = chat_complete(c, &mut mcp)
8962            .await
8963            .expect("should route hyphenated server name through mcp");
8964        assert_eq!(reply, "outlook routed correctly");
8965        assert_eq!(
8966            call_count.load(std::sync::atomic::Ordering::SeqCst),
8967            2,
8968            "must have completed both rounds"
8969        );
8970    }
8971
8972    /// OpenAI mirror of the Ollama cap-exit fallback: tool calls until the cap,
8973    /// then a 400 on the tools-disabled summary → the named fallback.
8974    struct OpenAiErrOnFinal;
8975    impl Respond for OpenAiErrOnFinal {
8976        fn respond(&self, req: &Request) -> ResponseTemplate {
8977            if body_json(req).get("tools").is_some() {
8978                ResponseTemplate::new(200).set_body_json(serde_json::json!({
8979                    "choices": [{"message": {
8980                        "content": null,
8981                        "tool_calls": [{
8982                            "id": "call_1",
8983                            "type": "function",
8984                            "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
8985                        }]
8986                    }}]
8987                }))
8988            } else {
8989                ResponseTemplate::new(400).set_body_string("bad request")
8990            }
8991        }
8992    }
8993
8994    #[tokio::test]
8995    async fn openai_cap_exit_fallback_when_final_summary_errors() {
8996        let server = MockServer::start().await;
8997        Mock::given(method("POST"))
8998            .and(path("/v1/chat/completions"))
8999            .respond_with(OpenAiErrOnFinal)
9000            .mount(&server)
9001            .await;
9002
9003        let messages = msgs();
9004        let caveats = Caveats::top();
9005        let uri = server.uri();
9006        let mut c = ctx(&uri, &messages, &caveats);
9007        c.kind = BackendKind::Openai;
9008        c.max_tool_rounds = 2;
9009        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
9010            .await
9011            .expect("must succeed even when the summary errors");
9012        assert!(reply.contains("tool-call limit of 2"), "got: {reply}");
9013        assert!(reply.contains("max_tool_rounds"));
9014    }
9015
9016    // -- Narrate-then-stop rescue: bounded no-tool-call auto-continue ---------
9017
9018    /// OpenAI responder that serves a scripted `choices[0].message` per request
9019    /// (by order); out-of-range requests repeat the last scripted entry.
9020    struct ScriptedOpenAi {
9021        round: Arc<AtomicUsize>,
9022        script: Vec<serde_json::Value>,
9023    }
9024    impl Respond for ScriptedOpenAi {
9025        fn respond(&self, _req: &Request) -> ResponseTemplate {
9026            let i = self.round.fetch_add(1, Ordering::SeqCst);
9027            let msg = self
9028                .script
9029                .get(i)
9030                .or_else(|| self.script.last())
9031                .cloned()
9032                .unwrap_or_else(|| serde_json::json!({ "content": "final." }));
9033            ResponseTemplate::new(200)
9034                .set_body_json(serde_json::json!({ "choices": [{ "message": msg }] }))
9035        }
9036    }
9037
9038    /// Drive the OpenAI loop over a per-round script; return `(reply, requests)`.
9039    async fn run_openai_script_with_ledger(
9040        script: Vec<serde_json::Value>,
9041        step_ledger: Option<&dyn StepLedger>,
9042    ) -> (String, usize) {
9043        let server = MockServer::start().await;
9044        let round = Arc::new(AtomicUsize::new(0));
9045        Mock::given(method("POST"))
9046            .and(path("/v1/chat/completions"))
9047            .respond_with(ScriptedOpenAi {
9048                round: round.clone(),
9049                script,
9050            })
9051            .mount(&server)
9052            .await;
9053        let messages = msgs();
9054        let caveats = Caveats::top();
9055        let uri = server.uri();
9056        let mut c = ctx(&uri, &messages, &caveats);
9057        c.kind = BackendKind::Openai;
9058        c.step_ledger = step_ledger;
9059        let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9060        (reply, round.load(Ordering::SeqCst))
9061    }
9062
9063    async fn run_openai_script(script: Vec<serde_json::Value>) -> (String, usize) {
9064        run_openai_script_with_ledger(script, None).await
9065    }
9066
9067    /// Like [`run_openai_script`] but with a configured narrate-then-stop
9068    /// rescue budget (`[tui] narration_nudge_cap`, lever L3).
9069    async fn run_openai_script_with_cap(
9070        script: Vec<serde_json::Value>,
9071        narration_nudge_cap: usize,
9072    ) -> (String, usize) {
9073        let server = MockServer::start().await;
9074        let round = Arc::new(AtomicUsize::new(0));
9075        Mock::given(method("POST"))
9076            .and(path("/v1/chat/completions"))
9077            .respond_with(ScriptedOpenAi {
9078                round: round.clone(),
9079                script,
9080            })
9081            .mount(&server)
9082            .await;
9083        let messages = msgs();
9084        let caveats = Caveats::top();
9085        let uri = server.uri();
9086        let mut c = ctx(&uri, &messages, &caveats);
9087        c.kind = BackendKind::Openai;
9088        c.narration_nudge_cap = narration_nudge_cap;
9089        let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9090        (reply, round.load(Ordering::SeqCst))
9091    }
9092
9093    #[tokio::test]
9094    async fn narrated_intent_with_no_tool_call_nudges_and_continues() {
9095        // The model narrates intent to act but calls no tool. Instead of ending
9096        // the turn (the bug), the loop nudges and runs another round, returning
9097        // the post-nudge answer.
9098        let (reply, rounds) = run_openai_script(vec![
9099            serde_json::json!({ "content": "Let me edit the file now." }),
9100            serde_json::json!({ "content": "All done — the edit is complete." }),
9101        ])
9102        .await;
9103        assert_eq!(rounds, 2, "must run a second round after the nudge");
9104        assert!(
9105            reply.contains("complete"),
9106            "returns the post-nudge answer: {reply}"
9107        );
9108        assert!(
9109            !reply.contains("Let me edit"),
9110            "must not return the narration: {reply}"
9111        );
9112    }
9113
9114    #[tokio::test]
9115    async fn narration_auto_continue_is_bounded_by_the_cap() {
9116        // The model narrates intent EVERY round. The cap (1) allows exactly one
9117        // nudge, then the narration is accepted as the final answer — no loop.
9118        let (reply, rounds) = run_openai_script(vec![
9119            serde_json::json!({ "content": "Let me keep editing now." }),
9120            serde_json::json!({ "content": "Let me keep editing now." }),
9121            serde_json::json!({ "content": "Let me keep editing now." }),
9122        ])
9123        .await;
9124        assert_eq!(
9125            rounds, 2,
9126            "exactly one nudge (cap=1), then accept, got {rounds}"
9127        );
9128        assert!(
9129            reply.contains("editing"),
9130            "narration accepted as final: {reply}"
9131        );
9132    }
9133
9134    #[tokio::test]
9135    async fn narration_nudge_cap_two_allows_a_second_escalated_rescue() {
9136        // Lever L3: with `narration_nudge_cap = 2` a chronic narrator gets TWO
9137        // rescues (the second escalated), and the post-rescue answer is
9138        // returned; a genuine recovery on round 3 proves the extra budget is
9139        // what converts the stall.
9140        let (reply, rounds) = run_openai_script_with_cap(
9141            vec![
9142                serde_json::json!({ "content": "Let me keep editing now." }),
9143                serde_json::json!({ "content": "Let me keep editing now." }),
9144                serde_json::json!({ "content": "All done — the edit is complete." }),
9145            ],
9146            2,
9147        )
9148        .await;
9149        assert_eq!(
9150            rounds, 3,
9151            "two nudges (cap=2) before the recovery, got {rounds}"
9152        );
9153        assert!(
9154            reply.contains("complete"),
9155            "returns the post-nudge answer: {reply}"
9156        );
9157    }
9158
9159    #[tokio::test]
9160    async fn narration_nudge_cap_two_still_accepts_after_exhaustion() {
9161        // The raised cap is still a cap: a model that narrates through both
9162        // rescues has its third narration accepted as the final answer.
9163        let (reply, rounds) = run_openai_script_with_cap(
9164            vec![
9165                serde_json::json!({ "content": "Let me keep editing now." }),
9166                serde_json::json!({ "content": "Let me keep editing now." }),
9167                serde_json::json!({ "content": "Let me keep editing now." }),
9168                serde_json::json!({ "content": "Let me keep editing now." }),
9169            ],
9170            2,
9171        )
9172        .await;
9173        assert_eq!(rounds, 3, "two nudges, then accept, got {rounds}");
9174        assert!(
9175            reply.contains("editing"),
9176            "narration accepted as final: {reply}"
9177        );
9178    }
9179
9180    #[test]
9181    fn escalated_narration_nudge_names_attempt_cap_and_active_step() {
9182        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
9183
9184        let ledger = SessionStepLedger::default();
9185        ledger.restore(&PlanSnapshot {
9186            steps: vec![
9187                Step {
9188                    description: "inspect".to_string(),
9189                    status: StepStatus::Done,
9190                },
9191                Step {
9192                    description: "fix conflict markers".to_string(),
9193                    status: StepStatus::Active,
9194                },
9195            ],
9196        });
9197        let text = escalated_narration_action_nudge(2, 3, Some(&ledger as &dyn StepLedger));
9198        assert!(text.contains("Reminder 2/3"), "{text}");
9199        assert!(text.contains("fix conflict markers"), "{text}");
9200        assert!(text.contains("tool call"), "{text}");
9201
9202        // No ledger: no step clause, the demand still stands.
9203        let bare = escalated_narration_action_nudge(2, 2, None);
9204        assert!(bare.contains("Reminder 2/2"), "{bare}");
9205        assert!(!bare.contains("Active step"), "{bare}");
9206    }
9207
9208    #[tokio::test]
9209    async fn accepted_narration_reports_cap_exhausted_end_reason() {
9210        // The acceptance-forensics record: a narration that exhausts the
9211        // rescue budget ends the turn with a visible reason instead of
9212        // masquerading as a normal completion.
9213        let server = MockServer::start().await;
9214        let round = Arc::new(AtomicUsize::new(0));
9215        Mock::given(method("POST"))
9216            .and(path("/v1/chat/completions"))
9217            .respond_with(ScriptedOpenAi {
9218                round: round.clone(),
9219                script: vec![
9220                    serde_json::json!({ "content": "Let me keep editing now." }),
9221                    serde_json::json!({ "content": "Let me keep editing now." }),
9222                ],
9223            })
9224            .mount(&server)
9225            .await;
9226        let messages = msgs();
9227        let caveats = Caveats::top();
9228        let uri = server.uri();
9229        let mut end_reason: Option<crate::TurnEndReason> = None;
9230        let mut c = ctx(&uri, &messages, &caveats);
9231        c.kind = BackendKind::Openai;
9232        c.end_reason = Some(&mut end_reason);
9233        let _ = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9234        assert_eq!(
9235            end_reason,
9236            Some(crate::TurnEndReason::NarrationCapExhausted)
9237        );
9238    }
9239
9240    #[tokio::test]
9241    async fn genuine_completion_reports_completed_end_reason() {
9242        let server = MockServer::start().await;
9243        let round = Arc::new(AtomicUsize::new(0));
9244        Mock::given(method("POST"))
9245            .and(path("/v1/chat/completions"))
9246            .respond_with(ScriptedOpenAi {
9247                round: round.clone(),
9248                script: vec![serde_json::json!({ "content": "The capital of France is Paris." })],
9249            })
9250            .mount(&server)
9251            .await;
9252        let messages = msgs();
9253        let caveats = Caveats::top();
9254        let uri = server.uri();
9255        let mut end_reason: Option<crate::TurnEndReason> = None;
9256        let mut c = ctx(&uri, &messages, &caveats);
9257        c.kind = BackendKind::Openai;
9258        c.end_reason = Some(&mut end_reason);
9259        let _ = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9260        assert_eq!(end_reason, Some(crate::TurnEndReason::Completed));
9261    }
9262
9263    #[tokio::test]
9264    async fn narration_nudge_reaches_the_wire_tagged_as_loop_guidance() {
9265        // The rescue nudge must arrive tagged so the compaction pipeline can
9266        // keep it (and the model's echo of it) out of later summaries.
9267        let server = MockServer::start().await;
9268        let saw_tag = Arc::new(AtomicBool::new(false));
9269
9270        struct TagProbe {
9271            saw_tag: Arc<AtomicBool>,
9272        }
9273        impl Respond for TagProbe {
9274            fn respond(&self, req: &Request) -> ResponseTemplate {
9275                let body = body_json(req);
9276                let tagged = body["messages"].as_array().is_some_and(|ms| {
9277                    ms.iter().any(|m| {
9278                        m["role"] == "user"
9279                            && m["content"]
9280                                .as_str()
9281                                .is_some_and(|c| c.starts_with(compress::LOOP_GUIDANCE_PREFIX))
9282                    })
9283                });
9284                if tagged {
9285                    self.saw_tag.store(true, Ordering::SeqCst);
9286                    return ResponseTemplate::new(200).set_body_json(serde_json::json!({
9287                        "choices": [{ "message": { "content": "All done — edit complete." } }]
9288                    }));
9289                }
9290                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9291                    "choices": [{ "message": { "content": "Let me edit the file now." } }]
9292                }))
9293            }
9294        }
9295        Mock::given(method("POST"))
9296            .and(path("/v1/chat/completions"))
9297            .respond_with(TagProbe {
9298                saw_tag: saw_tag.clone(),
9299            })
9300            .mount(&server)
9301            .await;
9302
9303        let messages = msgs();
9304        let caveats = Caveats::top();
9305        let uri = server.uri();
9306        let mut c = ctx(&uri, &messages, &caveats);
9307        c.kind = BackendKind::Openai;
9308        let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9309        assert!(
9310            saw_tag.load(Ordering::SeqCst),
9311            "the narration nudge must carry LOOP_GUIDANCE_PREFIX on the wire"
9312        );
9313        assert!(reply.contains("complete"), "{reply}");
9314    }
9315
9316    #[tokio::test]
9317    async fn ollama_loop_honors_cap_two_and_escalates_the_second_nudge() {
9318        // Ollama-path parity for lever L3 (the macro chain is separate code
9319        // from the OpenAI inline chain): with narration_nudge_cap = 2 the
9320        // first rescue carries the [loop-guidance]-tagged generic corrective
9321        // and the SECOND carries the escalated "Reminder 2/2" variant — both
9322        // observed on the wire — before the model recovers.
9323        let server = MockServer::start().await;
9324        let saw_first = Arc::new(AtomicBool::new(false));
9325        let saw_escalated = Arc::new(AtomicBool::new(false));
9326
9327        struct EscalationProbe {
9328            saw_first: Arc<AtomicBool>,
9329            saw_escalated: Arc<AtomicBool>,
9330        }
9331        impl Respond for EscalationProbe {
9332            fn respond(&self, req: &Request) -> ResponseTemplate {
9333                let body = body_json(req);
9334                let has = |needle: &str| {
9335                    body["messages"].as_array().is_some_and(|ms| {
9336                        ms.iter()
9337                            .any(|m| m["content"].as_str().is_some_and(|c| c.contains(needle)))
9338                    })
9339                };
9340                if has("Reminder 2/2") {
9341                    self.saw_escalated.store(true, Ordering::SeqCst);
9342                    return ResponseTemplate::new(200).set_body_json(serde_json::json!({
9343                        "message": { "content": "All done — the edit is complete." }
9344                    }));
9345                }
9346                if has(compress::LOOP_GUIDANCE_PREFIX) {
9347                    self.saw_first.store(true, Ordering::SeqCst);
9348                }
9349                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9350                    "message": { "content": "Let me keep editing now." }
9351                }))
9352            }
9353        }
9354        Mock::given(method("POST"))
9355            .and(path("/api/chat"))
9356            .respond_with(EscalationProbe {
9357                saw_first: saw_first.clone(),
9358                saw_escalated: saw_escalated.clone(),
9359            })
9360            .mount(&server)
9361            .await;
9362
9363        let messages = msgs();
9364        let caveats = Caveats::top();
9365        let uri = server.uri();
9366        let mut c = ctx(&uri, &messages, &caveats);
9367        c.kind = BackendKind::Ollama;
9368        c.narration_nudge_cap = 2;
9369        let (reply, _s, _u, _h) = chat_complete(c, &mut NoMcp).await.expect("dispatch");
9370        assert!(
9371            saw_first.load(Ordering::SeqCst),
9372            "the first nudge must reach the Ollama wire tagged [loop-guidance]"
9373        );
9374        assert!(
9375            saw_escalated.load(Ordering::SeqCst),
9376            "the second nudge must be the escalated Reminder 2/2 variant"
9377        );
9378        assert!(reply.contains("complete"), "{reply}");
9379    }
9380
9381    #[test]
9382    fn post_compaction_refunds_rescue_budget_and_appends_one_directive() {
9383        let directive_count = |messages: &[serde_json::Value]| {
9384            messages
9385                .iter()
9386                .filter(|m| {
9387                    m["content"]
9388                        .as_str()
9389                        .is_some_and(|c| c.starts_with(compress::CONTINUATION_PREFIX))
9390                })
9391                .count()
9392        };
9393        let mut messages = vec![
9394            serde_json::json!({"role": "system", "content": "you are a test"}),
9395            serde_json::json!({"role": "user", "content": "do the thing"}),
9396            serde_json::json!({
9397                "role": "user",
9398                "content": format!("{} stale directive", compress::CONTINUATION_PREFIX)
9399            }),
9400        ];
9401        let mut nudges = 1usize;
9402
9403        // Prune-only passes keep the corrective text: no refund, no anchor.
9404        apply_post_compaction_continuation(
9405            &mut messages,
9406            &mut nudges,
9407            CompressAction::Pruned,
9408            None,
9409            true,
9410        );
9411        assert_eq!(nudges, 1, "prune must not refund the rescue budget");
9412        assert_eq!(messages.len(), 3, "prune must not touch the directive");
9413
9414        // Round 0 (a FRESH turn whose between-turn growth fired the pre-send
9415        // compaction): no directive — "You are mid-task … do not summarize"
9416        // would countermand the operator's brand-new ask sitting above it.
9417        apply_post_compaction_continuation(
9418            &mut messages,
9419            &mut nudges,
9420            CompressAction::Summarized,
9421            None,
9422            false,
9423        );
9424        assert_eq!(nudges, 1, "round 0 must not touch the rescue budget");
9425        assert_eq!(messages.len(), 3, "round 0 must not inject the directive");
9426
9427        // A MID-TURN summarization refunds the budget, drops the stale
9428        // directive, and appends exactly one fresh act-now anchor as the last
9429        // user message.
9430        apply_post_compaction_continuation(
9431            &mut messages,
9432            &mut nudges,
9433            CompressAction::Summarized,
9434            None,
9435            true,
9436        );
9437        assert_eq!(nudges, 0, "summarization refunds the rescue budget");
9438        assert_eq!(directive_count(&messages), 1, "at most one directive alive");
9439        let last = messages.last().unwrap();
9440        assert_eq!(last["role"], "user");
9441        let content = last["content"].as_str().unwrap();
9442        assert!(
9443            content.starts_with(compress::CONTINUATION_PREFIX),
9444            "{content}"
9445        );
9446        assert!(content.contains("tool call"), "{content}");
9447        assert!(!content.contains("stale directive"), "{content}");
9448    }
9449
9450    #[tokio::test]
9451    async fn genuine_final_answer_is_not_nudged() {
9452        // No prior tool call and no intent-to-act cue → a real answer returns
9453        // immediately, un-nudged (no wasted round).
9454        let (reply, rounds) = run_openai_script(vec![
9455            serde_json::json!({ "content": "The capital of France is Paris." }),
9456        ])
9457        .await;
9458        assert_eq!(
9459            rounds, 1,
9460            "a plain final answer is not nudged, got {rounds}"
9461        );
9462        assert!(reply.contains("Paris"), "returns the answer: {reply}");
9463    }
9464
9465    #[tokio::test]
9466    async fn final_answer_after_a_tool_call_is_not_nudged() {
9467        // The normal "act, then conclude" turn: a tool call, then a cue-less
9468        // final answer. The rescue must NOT fire (no intent cue) — else every
9469        // ordinary tool-using turn would waste a round.
9470        let (reply, rounds) = run_openai_script(vec![
9471            serde_json::json!({
9472                "content": null,
9473                "tool_calls": [{
9474                    "id": "c1", "type": "function",
9475                    "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
9476                }]
9477            }),
9478            serde_json::json!({ "content": "The files were examined; everything checks out." }),
9479        ])
9480        .await;
9481        assert_eq!(
9482            rounds, 2,
9483            "tool call (r0) then final answer (r1) — no extra round, got {rounds}"
9484        );
9485        assert!(
9486            reply.contains("checks out"),
9487            "returns the final answer as-is: {reply}"
9488        );
9489    }
9490
9491    #[tokio::test]
9492    async fn observed_fix_intent_after_a_tool_call_nudges_and_continues() {
9493        // Live repro: after a read-only observation, the model identified the
9494        // exact edit but stopped on prose instead of calling the edit tool.
9495        let (reply, rounds) = run_openai_script(vec![
9496            serde_json::json!({
9497                "content": null,
9498                "tool_calls": [{
9499                    "id": "c1", "type": "function",
9500                    "function": { "name": "definitely_not_a_real_tool", "arguments": "{}" }
9501                }]
9502            }),
9503            serde_json::json!({
9504                "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."
9505            }),
9506            serde_json::json!({ "content": "The stray brace is removed and the compile error is fixed." }),
9507        ])
9508        .await;
9509        assert_eq!(
9510            rounds, 3,
9511            "tool call, narrated edit intent, then post-nudge answer; got {rounds}"
9512        );
9513        assert!(
9514            reply.contains("compile error is fixed"),
9515            "returns the post-nudge answer: {reply}"
9516        );
9517        assert!(
9518            !reply.contains("I need to remove"),
9519            "must not stop on the narrated edit intent: {reply}"
9520        );
9521    }
9522
9523    #[tokio::test]
9524    async fn pending_plan_final_answer_nudges_before_handoff() {
9525        let ledger = SessionStepLedger::default();
9526        ledger.restore(&PlanSnapshot {
9527            steps: vec![
9528                Step {
9529                    description: "convert help sections".to_string(),
9530                    status: StepStatus::Done,
9531                },
9532                Step {
9533                    description: "fix format_command_list and update lib.rs".to_string(),
9534                    status: StepStatus::Active,
9535                },
9536                Step {
9537                    description: "add tests".to_string(),
9538                    status: StepStatus::Todo,
9539                },
9540            ],
9541        });
9542        let (reply, rounds) = run_openai_script_with_ledger(
9543            vec![
9544                serde_json::json!({
9545                    "content": "I need to finish Step 2, then Steps 3-5."
9546                }),
9547                serde_json::json!({
9548                    "content": "Plan updated; continuing with the active step."
9549                }),
9550                serde_json::json!({
9551                    "content": "The active step is now complete."
9552                }),
9553            ],
9554            Some(&ledger as &dyn StepLedger),
9555        )
9556        .await;
9557        assert_eq!(
9558            rounds, 3,
9559            "open plan should force a completion-gate round and action-nudge follow-on narration"
9560        );
9561        assert!(
9562            reply.contains("complete"),
9563            "returns the post-nudge answer: {reply}"
9564        );
9565        assert!(
9566            !reply.contains("I need to finish"),
9567            "must not accept a plain handoff while plan is open: {reply}"
9568        );
9569    }
9570
9571    #[tokio::test]
9572    async fn findings_summary_with_stale_plan_nudges_update_plan_then_continues() {
9573        let ledger = SessionStepLedger::default();
9574        ledger.restore(&PlanSnapshot {
9575            steps: vec![
9576                Step {
9577                    description: "convert help sections".to_string(),
9578                    status: StepStatus::Done,
9579                },
9580                Step {
9581                    description: "wire progressive dispatch in lib.rs".to_string(),
9582                    status: StepStatus::Active,
9583                },
9584                Step {
9585                    description: "add tests".to_string(),
9586                    status: StepStatus::Todo,
9587                },
9588            ],
9589        });
9590        let findings = "\
9591Summary of Findings
9592
9593Across the tool calls, I observed two issues in newt-tui/src/help_sections.rs:
95941. Duplicate function definitions
95952. Stray closing brace
9596
9597Current Status
9598
9599The 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.
9600
9601Next Steps Required
9602
9603To 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.
9604
9605However, I've reached the tool-call limit and cannot make these edits now.";
9606        let (reply, rounds) = run_openai_script_with_ledger(
9607            vec![
9608                serde_json::json!({ "content": findings }),
9609                serde_json::json!({
9610                    "content": null,
9611                    "tool_calls": [{
9612                        "id": "plan_1",
9613                        "type": "function",
9614                        "function": {
9615                            "name": "update_plan",
9616                            "arguments": serde_json::json!({
9617                                "plan": [
9618                                    {"step": "fix duplicate help rollup functions and stray brace", "status": "in_progress"},
9619                                    {"step": "wire progressive dispatch in lib.rs", "status": "pending"},
9620                                    {"step": "add rollup tests", "status": "pending"}
9621                                ]
9622                            }).to_string()
9623                        }
9624                    }]
9625                }),
9626                serde_json::json!({
9627                    "content": null,
9628                    "tool_calls": [{
9629                        "id": "edit_1",
9630                        "type": "function",
9631                        "function": {
9632                            "name": "definitely_not_a_real_tool",
9633                            "arguments": "{}"
9634                        }
9635                    }]
9636                }),
9637                serde_json::json!({ "content": "Done." }),
9638            ],
9639            Some(&ledger as &dyn StepLedger),
9640        )
9641        .await;
9642        assert_eq!(
9643            rounds, 4,
9644            "findings summary should be nudged into update_plan, then a concrete tool"
9645        );
9646        assert_eq!(reply, "Done.");
9647        assert!(
9648            !reply.contains("tool-call limit"),
9649            "must not accept the handoff summary: {reply}"
9650        );
9651        let snap = ledger.snapshot();
9652        assert_eq!(
9653            snap.steps[0].description,
9654            "fix duplicate help rollup functions and stray brace"
9655        );
9656        assert_eq!(snap.steps[0].status, StepStatus::Active);
9657    }
9658
9659    #[tokio::test]
9660    async fn completed_plan_final_answer_is_accepted() {
9661        let ledger = SessionStepLedger::default();
9662        ledger.restore(&PlanSnapshot {
9663            steps: vec![Step {
9664                description: "done".to_string(),
9665                status: StepStatus::Done,
9666            }],
9667        });
9668        let (reply, rounds) = run_openai_script_with_ledger(
9669            vec![serde_json::json!({
9670                "content": "All plan steps are complete."
9671            })],
9672            Some(&ledger as &dyn StepLedger),
9673        )
9674        .await;
9675        assert_eq!(rounds, 1, "completed plan must not be nudged");
9676        assert!(reply.contains("complete"), "returns final answer: {reply}");
9677    }
9678
9679    #[tokio::test]
9680    async fn continuing_with_active_step_after_plan_nudge_gets_action_nudge() {
9681        let ledger = SessionStepLedger::default();
9682        ledger.restore(&PlanSnapshot {
9683            steps: vec![
9684                Step {
9685                    description: "convert help sections".to_string(),
9686                    status: StepStatus::Done,
9687                },
9688                Step {
9689                    description: "insert progressive dispatch".to_string(),
9690                    status: StepStatus::Active,
9691                },
9692                Step {
9693                    description: "add tests".to_string(),
9694                    status: StepStatus::Todo,
9695                },
9696            ],
9697        });
9698        let (reply, rounds) = run_openai_script_with_ledger(
9699            vec![
9700                serde_json::json!({
9701                    "content": "I need to finish Step 2, then Steps 3-5."
9702                }),
9703                serde_json::json!({
9704                    "content": "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
9705                }),
9706                serde_json::json!({
9707                    "content": "The edit is now complete."
9708                }),
9709            ],
9710            Some(&ledger as &dyn StepLedger),
9711        )
9712        .await;
9713        assert_eq!(
9714            rounds, 3,
9715            "plan nudge should be followed by an action nudge for continuing-with narration"
9716        );
9717        assert!(
9718            reply.contains("complete"),
9719            "returns the post-action-nudge answer: {reply}"
9720        );
9721        assert!(
9722            !reply.contains("Continuing with step 2"),
9723            "must not stop on the continuing-with narration: {reply}"
9724        );
9725    }
9726
9727    #[tokio::test]
9728    async fn stale_file_blocker_nudges_ground_truth_check_and_continues() {
9729        let blocker = "\
9730Summary
9731
9732What happened: The lib.rs file I was editing grew from ~9400 to ~16808 lines \
9733between reads — likely modified concurrently by another agent or tool. This \
9734means my old edit contexts are stale.
9735
9736Why I'm blocked: I cannot safely use edit_file on lib.rs because the file has \
9737been modified out from under me. My old line references and context are invalid \
9738for an 8400-line larger file.
9739
9740Final Answer / Recommendation
9741
9742The operator should restore lib.rs to a known-good state (e.g., git checkout \
9743newt-tui/src/lib.rs).";
9744        let (reply, rounds) = run_openai_script(vec![
9745            serde_json::json!({ "content": blocker }),
9746            serde_json::json!({ "content": "Ground truth checked; lib.rs is clean, so I am continuing." }),
9747        ])
9748        .await;
9749        assert_eq!(
9750            rounds, 2,
9751            "stale-file blocker should get one verification nudge"
9752        );
9753        assert!(
9754            reply.contains("lib.rs is clean"),
9755            "returns the post-nudge answer: {reply}"
9756        );
9757        assert!(
9758            !reply.contains("git checkout"),
9759            "must not accept the unverified revert recommendation: {reply}"
9760        );
9761    }
9762
9763    #[test]
9764    fn looks_like_intent_to_act_separates_narration_from_final_answers() {
9765        // Real repro narrations that ended a turn — must read as intent-to-act.
9766        assert!(looks_like_intent_to_act(
9767            "Now I have everything I need. Let me make both edits now."
9768        ));
9769        assert!(looks_like_intent_to_act(
9770            "Now I'll add the --home flag to the Cli struct."
9771        ));
9772        assert!(looks_like_intent_to_act("Let me keep editing now."));
9773        assert!(looks_like_intent_to_act(
9774            "I'm going to edit the config file."
9775        ));
9776        assert!(looks_like_intent_to_act(
9777            "Let me understand what was already done on this branch and compare it with the issue requirements."
9778        ));
9779        assert!(looks_like_intent_to_act(
9780            "Let me check the current implementation and identify any gaps."
9781        ));
9782        assert!(looks_like_intent_to_act(
9783            "The help section logic itself has no tests yet.\n\nLet me commit this first step, then move on:"
9784        ));
9785        assert!(looks_like_intent_to_act(
9786            "Plan is current — no update needed. Continuing with step 2: inserting the progressive dispatch into lib.rs."
9787        ));
9788        assert!(looks_like_intent_to_act(
9789            "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."
9790        ));
9791        // Genuine sign-offs / answers — must NOT be nudged.
9792        assert!(!looks_like_intent_to_act("The capital of France is Paris."));
9793        assert!(!looks_like_intent_to_act(
9794            "I have finished editing the file and the tests pass."
9795        ));
9796        assert!(!looks_like_intent_to_act(
9797            "Here is a summary of what I found across the tool calls."
9798        ));
9799        // Borrowed-cue sign-off ("let me know" + a verb) — must NOT be nudged.
9800        assert!(!looks_like_intent_to_act(
9801            "Done. Let me know if you want any further changes."
9802        ));
9803        // A long narration whose 400-byte tail cut lands mid-multibyte-glyph
9804        // (each `…` is 3 bytes; 200 of them puts the cut at byte 211, not a char
9805        // boundary) must not panic the slice — and still classify as intent.
9806        let multibyte = format!("{}let me edit", "…".repeat(200));
9807        assert!(looks_like_intent_to_act(&multibyte));
9808    }
9809
9810    #[test]
9811    fn looks_like_unverified_stale_file_blocker_requires_file_stale_and_blocker_cues() {
9812        assert!(looks_like_unverified_stale_file_blocker(
9813            "The lib.rs file I was editing grew from ~9400 to ~16808 lines between reads. \
9814             Why I'm blocked: I cannot safely use edit_file because the file has been \
9815             modified out from under me. The operator should restore lib.rs."
9816        ));
9817        assert!(looks_like_unverified_stale_file_blocker(
9818            "My old line references are invalid and the context is stale. Any edit could \
9819             land in the wrong place and corrupt the code; recommendation: restore the file."
9820        ));
9821        assert!(!looks_like_unverified_stale_file_blocker(
9822            "The cache entry is stale, so I refreshed it and continued."
9823        ));
9824        assert!(!looks_like_unverified_stale_file_blocker(
9825            "I checked git diff and the file is clean, so I can continue from the verified contents."
9826        ));
9827    }
9828
9829    #[test]
9830    fn stale_file_ground_truth_nudge_names_read_only_checks_and_revert_guard() {
9831        let nudge = stale_file_ground_truth_nudge();
9832        assert!(nudge.contains("git status --short"), "{nudge}");
9833        assert!(nudge.contains("git diff -- <file>"), "{nudge}");
9834        assert!(nudge.contains("wc -l <file>"), "{nudge}");
9835        assert!(nudge.contains("re-read the exact target range"), "{nudge}");
9836        assert!(
9837            nudge.contains("Never recommend git checkout/revert"),
9838            "{nudge}"
9839        );
9840    }
9841}
9842
9843// ---------------------------------------------------------------------------
9844// save_note tool + memory nudge — loop integration (Step 19.3, #248)
9845// ---------------------------------------------------------------------------
9846//
9847// Wiremock-backed tests against both agentic loops, pinning:
9848//   (1) save_note is advertised iff a NoteSink is present, and a save_note
9849//       tool call routes through the sink with the result fed back;
9850//   (2) the in-band memory nudge is appended to the user message when due —
9851//       and ONLY when a sink exists;
9852//   (3) organic save_note use resets the nudge counter (the read-only-rounds
9853//       reset pattern, hermes's reset-on-memory-write).
9854#[cfg(test)]
9855mod save_note_loop_tests {
9856    use super::note_sink::tests::MockSink;
9857    use super::*;
9858    use crate::caveats::Caveats;
9859    use crate::{BackendKind, MemMessage};
9860    use std::sync::atomic::{AtomicBool, Ordering};
9861    use std::sync::Arc;
9862    use wiremock::matchers::{method, path};
9863    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
9864
9865    fn msgs() -> Vec<MemMessage> {
9866        vec![
9867            MemMessage::system("you are a test"),
9868            MemMessage::user("do the thing"),
9869        ]
9870    }
9871
9872    fn ctx<'a>(
9873        server_uri: &'a str,
9874        messages: &'a [MemMessage],
9875        caveats: &'a Caveats,
9876    ) -> ChatCtx<'a> {
9877        ChatCtx {
9878            url: server_uri,
9879            model: "test-model",
9880            kind: BackendKind::Ollama,
9881            api_key: None,
9882            messages,
9883            task: "do the thing",
9884            workspace: ".",
9885            color: false,
9886            markdown: false,
9887            tool_offload: false,
9888            spill_store: None,
9889            compaction_store: None,
9890            scratchpad: false,
9891            scratchpad_store: None,
9892            code_search: None,
9893            experience_store: None,
9894            step_ledger: None,
9895            caveats,
9896            persona_tools: None,
9897            max_tool_rounds: 6,
9898            narration_nudge_cap: 1,
9899            workflow_grace_rounds: 0,
9900            tool_output_lines: 20,
9901            debug: false,
9902            trace: false,
9903            num_ctx: None,
9904            input_ceiling_pct: 80,
9905            low_budget_pct: 15,
9906            connect_timeout_secs: 5,
9907            inference_timeout_secs: 30,
9908            mid_loop_trim_threshold: 40,
9909            mid_loop_trim_tokens: None,
9910            max_ok_input: None,
9911            build_check_cmd: None,
9912            safe_context: None,
9913            recover_cw_400: None,
9914            note_sink: None,
9915            note_nudge: None,
9916            recall_source: None,
9917            memory_source: None,
9918            summarizer: None,
9919            compress_state: None,
9920            tool_events: None,
9921            phantom_reaches: None,
9922            end_reason: None,
9923            permission_gate: None,
9924            on_round_usage: None,
9925            estimate_ratio: None,
9926            estimation: crate::tokens::TokenEstimation::default(),
9927            summary_input_cap_floor_chars: 8_192,
9928            exec_floor: None,
9929            write_ledger: None,
9930            cancel: None,
9931            git_tool: None,
9932            crew_runner: None,
9933        }
9934    }
9935
9936    fn body_json(req: &Request) -> serde_json::Value {
9937        serde_json::from_slice(&req.body).unwrap_or_default()
9938    }
9939
9940    fn advertised_tool_names(body: &serde_json::Value) -> Vec<String> {
9941        body["tools"]
9942            .as_array()
9943            .map(|a| {
9944                a.iter()
9945                    .filter_map(|d| d["function"]["name"].as_str())
9946                    .map(String::from)
9947                    .collect()
9948            })
9949            .unwrap_or_default()
9950    }
9951
9952    fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
9953        body["messages"]
9954            .as_array()
9955            .map(|msgs| {
9956                msgs.iter().any(|m| {
9957                    m["content"]
9958                        .as_str()
9959                        .map(|c| c.contains(needle))
9960                        .unwrap_or(false)
9961                })
9962            })
9963            .unwrap_or(false)
9964    }
9965
9966    /// Ollama-shaped responder: issues one save_note tool call, then a final
9967    /// text answer once the "note saved:" tool result is visible in history.
9968    /// Also records whether save_note was advertised and whether the memory
9969    /// nudge line reached the model.
9970    struct SaveNoteResponder {
9971        save_note_advertised: Arc<AtomicBool>,
9972        nudge_seen: Arc<AtomicBool>,
9973        final_answer: String,
9974    }
9975
9976    impl Respond for SaveNoteResponder {
9977        fn respond(&self, req: &Request) -> ResponseTemplate {
9978            let body = body_json(req);
9979            if advertised_tool_names(&body).contains(&"save_note".to_string()) {
9980                self.save_note_advertised.store(true, Ordering::SeqCst);
9981            }
9982            if messages_contain(&body, "[system reminder:")
9983                && messages_contain(&body, "without a saved note")
9984            {
9985                self.nudge_seen.store(true, Ordering::SeqCst);
9986            }
9987            if messages_contain(&body, "note saved:") {
9988                // The tool result round-tripped — answer for real now.
9989                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9990                    "message": { "content": self.final_answer }
9991                }))
9992            } else if body.get("tools").is_some() {
9993                ResponseTemplate::new(200).set_body_json(serde_json::json!({
9994                    "message": {
9995                        "content": "",
9996                        "tool_calls": [{ "function": {
9997                            "name": "save_note",
9998                            "arguments": {
9999                                "action": "add",
10000                                "text": "user prefers vi keybindings"
10001                            }
10002                        }}]
10003                    }
10004                }))
10005            } else {
10006                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10007                    "message": { "content": "final summary" }
10008                }))
10009            }
10010        }
10011    }
10012
10013    #[tokio::test]
10014    async fn ollama_save_note_routes_to_sink_and_result_feeds_back() {
10015        let server = MockServer::start().await;
10016        let advertised = Arc::new(AtomicBool::new(false));
10017        let nudge_seen = Arc::new(AtomicBool::new(false));
10018        Mock::given(method("POST"))
10019            .and(path("/api/chat"))
10020            .respond_with(SaveNoteResponder {
10021                save_note_advertised: advertised.clone(),
10022                nudge_seen: nudge_seen.clone(),
10023                final_answer: "noted, moving on".into(),
10024            })
10025            .mount(&server)
10026            .await;
10027
10028        let messages = msgs();
10029        let caveats = Caveats::top();
10030        let uri = server.uri();
10031        let mut sink = MockSink::default();
10032        let mut c = ctx(&uri, &messages, &caveats);
10033        c.note_sink = Some(&mut sink);
10034        let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
10035            .await
10036            .expect("chat_complete should succeed");
10037
10038        assert!(
10039            advertised.load(Ordering::SeqCst),
10040            "save_note must be advertised when a sink is present"
10041        );
10042        assert_eq!(
10043            sink.calls,
10044            vec!["add:user prefers vi keybindings"],
10045            "the tool call must route through the sink"
10046        );
10047        assert_eq!(reply, "noted, moving on");
10048        assert_eq!(hallu, 0, "save_note is a real tool, not a hallucination");
10049        assert!(
10050            !nudge_seen.load(Ordering::SeqCst),
10051            "no nudge configured — none may be injected"
10052        );
10053    }
10054
10055    /// Without a sink the tool must be absent from the advertised set, and a
10056    /// configured nudge must NOT be appended (absent-without-sink).
10057    struct NoSinkObserver {
10058        save_note_advertised: Arc<AtomicBool>,
10059        nudge_seen: Arc<AtomicBool>,
10060    }
10061
10062    impl Respond for NoSinkObserver {
10063        fn respond(&self, req: &Request) -> ResponseTemplate {
10064            let body = body_json(req);
10065            if advertised_tool_names(&body).contains(&"save_note".to_string()) {
10066                self.save_note_advertised.store(true, Ordering::SeqCst);
10067            }
10068            if messages_contain(&body, "[system reminder:") {
10069                self.nudge_seen.store(true, Ordering::SeqCst);
10070            }
10071            ResponseTemplate::new(200).set_body_json(serde_json::json!({
10072                "message": { "content": "plain answer" }
10073            }))
10074        }
10075    }
10076
10077    #[tokio::test]
10078    async fn without_sink_no_tool_and_no_nudge_even_when_due() {
10079        let server = MockServer::start().await;
10080        let advertised = Arc::new(AtomicBool::new(false));
10081        let nudge_seen = Arc::new(AtomicBool::new(false));
10082        Mock::given(method("POST"))
10083            .and(path("/api/chat"))
10084            .respond_with(NoSinkObserver {
10085                save_note_advertised: advertised.clone(),
10086                nudge_seen: nudge_seen.clone(),
10087            })
10088            .mount(&server)
10089            .await;
10090
10091        let messages = msgs();
10092        let caveats = Caveats::top();
10093        let uri = server.uri();
10094        // A nudge that is overdue (interval 1, one quiet turn already counted)…
10095        let mut nudge = NoteNudge::new(1);
10096        let _ = nudge.begin_turn();
10097        let mut c = ctx(&uri, &messages, &caveats);
10098        // …but NO sink: the loop must neither advertise nor nudge.
10099        c.note_nudge = Some(&mut nudge);
10100        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10101            .await
10102            .expect("chat_complete should succeed");
10103
10104        assert_eq!(reply, "plain answer");
10105        assert!(
10106            !advertised.load(Ordering::SeqCst),
10107            "save_note advertised without a sink"
10108        );
10109        assert!(
10110            !nudge_seen.load(Ordering::SeqCst),
10111            "nudge injected without a sink"
10112        );
10113    }
10114
10115    #[tokio::test]
10116    async fn nudge_appended_to_user_message_when_due() {
10117        let server = MockServer::start().await;
10118        let advertised = Arc::new(AtomicBool::new(false));
10119        let nudge_seen = Arc::new(AtomicBool::new(false));
10120        Mock::given(method("POST"))
10121            .and(path("/api/chat"))
10122            .respond_with(NoSinkObserver {
10123                save_note_advertised: advertised.clone(),
10124                nudge_seen: nudge_seen.clone(),
10125            })
10126            .mount(&server)
10127            .await;
10128
10129        let messages = msgs();
10130        let caveats = Caveats::top();
10131        let uri = server.uri();
10132        let mut sink = MockSink::default();
10133        // One quiet turn already elapsed → due on this (the next) turn.
10134        let mut nudge = NoteNudge::new(1);
10135        let _ = nudge.begin_turn();
10136        let mut c = ctx(&uri, &messages, &caveats);
10137        c.note_sink = Some(&mut sink);
10138        c.note_nudge = Some(&mut nudge);
10139        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10140            .await
10141            .expect("chat_complete should succeed");
10142
10143        assert_eq!(reply, "plain answer");
10144        assert!(
10145            nudge_seen.load(Ordering::SeqCst),
10146            "the reminder line must reach the model on the due turn"
10147        );
10148    }
10149
10150    #[tokio::test]
10151    async fn organic_save_resets_the_nudge_counter() {
10152        let server = MockServer::start().await;
10153        let advertised = Arc::new(AtomicBool::new(false));
10154        let nudge_seen = Arc::new(AtomicBool::new(false));
10155        Mock::given(method("POST"))
10156            .and(path("/api/chat"))
10157            .respond_with(SaveNoteResponder {
10158                save_note_advertised: advertised.clone(),
10159                nudge_seen: nudge_seen.clone(),
10160                final_answer: "done".into(),
10161            })
10162            .mount(&server)
10163            .await;
10164
10165        let messages = msgs();
10166        let caveats = Caveats::top();
10167        let uri = server.uri();
10168        let mut sink = MockSink::default();
10169        let mut nudge = NoteNudge::new(1);
10170        let mut c = ctx(&uri, &messages, &caveats);
10171        c.note_sink = Some(&mut sink);
10172        c.note_nudge = Some(&mut nudge);
10173        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10174            .await
10175            .expect("chat_complete should succeed");
10176        assert_eq!(reply, "done");
10177        assert_eq!(sink.calls.len(), 1, "the model saved organically");
10178
10179        // The turn included an organic save → the counter restarted, so the
10180        // next turn must NOT be nudged (without the save, interval=1 would
10181        // have made it due).
10182        assert!(
10183            nudge.begin_turn().is_none(),
10184            "organic save_note use must reset the nudge counter"
10185        );
10186    }
10187
10188    /// OpenAI-shaped mirror: save_note advertised + routed, nudge appended.
10189    struct OpenAiSaveNoteResponder {
10190        save_note_advertised: Arc<AtomicBool>,
10191        nudge_seen: Arc<AtomicBool>,
10192    }
10193
10194    impl Respond for OpenAiSaveNoteResponder {
10195        fn respond(&self, req: &Request) -> ResponseTemplate {
10196            let body = body_json(req);
10197            if advertised_tool_names(&body).contains(&"save_note".to_string()) {
10198                self.save_note_advertised.store(true, Ordering::SeqCst);
10199            }
10200            if messages_contain(&body, "[system reminder:") {
10201                self.nudge_seen.store(true, Ordering::SeqCst);
10202            }
10203            if messages_contain(&body, "note saved:") {
10204                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10205                    "choices": [{ "message": { "content": "openai noted" } }]
10206                }))
10207            } else {
10208                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10209                    "choices": [{ "message": {
10210                        "content": null,
10211                        "tool_calls": [{
10212                            "id": "call_1",
10213                            "type": "function",
10214                            "function": {
10215                                "name": "save_note",
10216                                "arguments": "{\"action\":\"add\",\"text\":\"CI gate is just check\"}"
10217                            }
10218                        }]
10219                    }}]
10220                }))
10221            }
10222        }
10223    }
10224
10225    #[tokio::test]
10226    async fn openai_save_note_routes_and_nudge_appends() {
10227        let server = MockServer::start().await;
10228        let advertised = Arc::new(AtomicBool::new(false));
10229        let nudge_seen = Arc::new(AtomicBool::new(false));
10230        Mock::given(method("POST"))
10231            .and(path("/v1/chat/completions"))
10232            .respond_with(OpenAiSaveNoteResponder {
10233                save_note_advertised: advertised.clone(),
10234                nudge_seen: nudge_seen.clone(),
10235            })
10236            .mount(&server)
10237            .await;
10238
10239        let messages = msgs();
10240        let caveats = Caveats::top();
10241        let uri = server.uri();
10242        let mut sink = MockSink::default();
10243        let mut nudge = NoteNudge::new(1);
10244        let _ = nudge.begin_turn(); // due on this turn
10245        let mut c = ctx(&uri, &messages, &caveats);
10246        c.kind = BackendKind::Openai;
10247        c.note_sink = Some(&mut sink);
10248        c.note_nudge = Some(&mut nudge);
10249        let (reply, _, _, hallu) = chat_complete(c, &mut NoMcp)
10250            .await
10251            .expect("openai loop should succeed");
10252
10253        assert_eq!(reply, "openai noted");
10254        assert_eq!(sink.calls, vec!["add:CI gate is just check"]);
10255        assert!(advertised.load(Ordering::SeqCst));
10256        assert!(nudge_seen.load(Ordering::SeqCst));
10257        assert_eq!(hallu, 0);
10258    }
10259
10260    /// A sink error (here: the 19.1 over-budget curator error) must round-trip
10261    /// to the model verbatim as the tool result so it can replace/remove and
10262    /// retry — pinned end-to-end through the loop.
10263    struct ErrorEchoResponder {
10264        error_seen_by_model: Arc<AtomicBool>,
10265    }
10266
10267    impl Respond for ErrorEchoResponder {
10268        fn respond(&self, req: &Request) -> ResponseTemplate {
10269            let body = body_json(req);
10270            if messages_contain(&body, "Replace or remove existing entries first")
10271                && messages_contain(&body, "1. an existing entry")
10272            {
10273                self.error_seen_by_model.store(true, Ordering::SeqCst);
10274                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10275                    "message": { "content": "I will curate first" }
10276                }))
10277            } else if body.get("tools").is_some() {
10278                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10279                    "message": {
10280                        "content": "",
10281                        "tool_calls": [{ "function": {
10282                            "name": "save_note",
10283                            "arguments": { "action": "add", "text": "too big" }
10284                        }}]
10285                    }
10286                }))
10287            } else {
10288                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10289                    "message": { "content": "final summary" }
10290                }))
10291            }
10292        }
10293    }
10294
10295    #[tokio::test]
10296    async fn over_budget_error_round_trips_to_the_model() {
10297        let server = MockServer::start().await;
10298        let error_seen = Arc::new(AtomicBool::new(false));
10299        Mock::given(method("POST"))
10300            .and(path("/api/chat"))
10301            .respond_with(ErrorEchoResponder {
10302                error_seen_by_model: error_seen.clone(),
10303            })
10304            .mount(&server)
10305            .await;
10306
10307        let messages = msgs();
10308        let caveats = Caveats::top();
10309        let uri = server.uri();
10310        let mut sink = MockSink {
10311            fail_with: Some(
10312                "NOTES.md is full: this write needs 99/50 chars. \
10313                 Replace or remove existing entries first.\nCurrent entries:\n  1. an existing entry"
10314                    .into(),
10315            ),
10316            ..Default::default()
10317        };
10318        let mut c = ctx(&uri, &messages, &caveats);
10319        c.note_sink = Some(&mut sink);
10320        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10321            .await
10322            .expect("chat_complete should succeed");
10323
10324        assert_eq!(reply, "I will curate first");
10325        assert!(
10326            error_seen.load(Ordering::SeqCst),
10327            "the curator error (full entry list + instruction) must reach the model verbatim"
10328        );
10329    }
10330}
10331
10332// ---------------------------------------------------------------------------
10333// Compression v2 — summarize, don't discard (Step 18.4, #247)
10334// ---------------------------------------------------------------------------
10335//
10336// End-to-end wiremock tests for the compression pipeline wired into both
10337// loops. The headline property is B5's acceptance criterion from the context
10338// baseline (docs/testing/results/context-baseline-f0f4f6e.md): a long
10339// tool-heavy conversation crosses the token budget, compression fires, and
10340// the ORIGINAL TASK still reaches the next request — where the baseline
10341// measured 9/10 silently wrong answers because truncation discarded it (B6).
10342#[cfg(test)]
10343mod compression_loop_tests {
10344    use super::*;
10345    use crate::caveats::Caveats;
10346    use crate::{BackendKind, MemMessage};
10347    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10348    use std::sync::{Arc, Mutex};
10349    use wiremock::matchers::{method, path};
10350    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
10351
10352    const TASK: &str =
10353        "ACTIVE TASK GAUNTLET-7f3d9c: read big.txt until told to stop, then restate this marker";
10354    const CANNED_SUMMARY: &str =
10355        "## Active Task\nACTIVE TASK GAUNTLET-7f3d9c (canned summary)\n## Completed Actions\n1. read big.txt";
10356
10357    fn msgs() -> Vec<MemMessage> {
10358        vec![MemMessage::system("you are a test"), MemMessage::user(TASK)]
10359    }
10360
10361    fn ctx<'a>(
10362        server_uri: &'a str,
10363        messages: &'a [MemMessage],
10364        caveats: &'a Caveats,
10365        workspace: &'a str,
10366    ) -> ChatCtx<'a> {
10367        ChatCtx {
10368            url: server_uri,
10369            model: "test-model",
10370            kind: BackendKind::Ollama,
10371            api_key: None,
10372            messages,
10373            task: TASK,
10374            workspace,
10375            color: false,
10376            markdown: false,
10377            tool_offload: false,
10378            spill_store: None,
10379            compaction_store: None,
10380            scratchpad: false,
10381            scratchpad_store: None,
10382            code_search: None,
10383            experience_store: None,
10384            step_ledger: None,
10385            caveats,
10386            persona_tools: None,
10387            max_tool_rounds: 12,
10388            narration_nudge_cap: 1,
10389            workflow_grace_rounds: 0,
10390            tool_output_lines: 2,
10391            debug: false,
10392            trace: false,
10393            num_ctx: None,
10394            input_ceiling_pct: 80,
10395            low_budget_pct: 15,
10396            connect_timeout_secs: 5,
10397            inference_timeout_secs: 30,
10398            mid_loop_trim_threshold: 40,
10399            // The token trigger under test: well below what a few 4 KB
10400            // tool results accumulate to.
10401            mid_loop_trim_tokens: Some(5_000),
10402            max_ok_input: None,
10403            build_check_cmd: None,
10404            safe_context: None,
10405            recover_cw_400: None,
10406            note_sink: None,
10407            note_nudge: None,
10408            recall_source: None,
10409            memory_source: None,
10410            summarizer: None,
10411            compress_state: None,
10412            tool_events: None,
10413            phantom_reaches: None,
10414            end_reason: None,
10415            permission_gate: None,
10416            on_round_usage: None,
10417            estimate_ratio: None,
10418            estimation: crate::tokens::TokenEstimation::default(),
10419            summary_input_cap_floor_chars: 8_192,
10420            exec_floor: None,
10421            write_ledger: None,
10422            cancel: None,
10423            git_tool: None,
10424            crew_runner: None,
10425        }
10426    }
10427
10428    /// Workspace with one ~4 KB file the mock model reads over and over.
10429    fn gauntlet_workspace() -> tempfile::TempDir {
10430        let ws = tempfile::TempDir::new().unwrap();
10431        let line = "the quick brown newt compresses context without discarding it\n";
10432        std::fs::write(ws.path().join("big.txt"), line.repeat(64)).unwrap();
10433        ws
10434    }
10435
10436    fn body_json(req: &Request) -> serde_json::Value {
10437        serde_json::from_slice(&req.body).unwrap_or_default()
10438    }
10439
10440    fn messages_contain(body: &serde_json::Value, needle: &str) -> bool {
10441        body["messages"]
10442            .as_array()
10443            .map(|msgs| {
10444                msgs.iter().any(|m| {
10445                    m["content"]
10446                        .as_str()
10447                        .map(|c| c.contains(needle))
10448                        .unwrap_or(false)
10449                })
10450            })
10451            .unwrap_or(false)
10452    }
10453
10454    /// chars/4 estimate of a request's message list (mirrors the loop's
10455    /// fallback estimator) — used to measure the reclaim across requests.
10456    fn body_message_tokens(body: &serde_json::Value) -> usize {
10457        body["messages"]
10458            .as_array()
10459            .map(|msgs| {
10460                msgs.iter()
10461                    .map(|m| {
10462                        crate::tokens::TokenEstimation::default()
10463                            .tokens_for_chars(m.to_string().chars().count())
10464                    })
10465                    .sum()
10466            })
10467            .unwrap_or(0)
10468    }
10469
10470    /// A summarizer that records every request it receives and returns the
10471    /// canned summary.
10472    fn canned_summarizer(prompts: Arc<Mutex<Vec<String>>>) -> Summarizer {
10473        Box::new(move |prompt: String| {
10474            let prompts = prompts.clone();
10475            Box::pin(async move {
10476                prompts.lock().unwrap().push(prompt);
10477                Ok(CANNED_SUMMARY.to_string())
10478            })
10479        })
10480    }
10481
10482    /// Ollama-shaped gauntlet responder: keeps demanding `read_file` of the
10483    /// big fixture until the compaction marker shows up in the request, then
10484    /// answers. Records per-request observations the assertions need.
10485    struct GauntletResponder {
10486        final_answer: String,
10487        /// `(had_marker, est_message_tokens)` per non-streaming request.
10488        log: Arc<Mutex<Vec<(bool, usize)>>>,
10489        task_in_marker_request: Arc<AtomicBool>,
10490        summary_in_marker_request: Arc<AtomicBool>,
10491        old_placeholder_seen: Arc<AtomicBool>,
10492        static_marker_instead: bool,
10493    }
10494
10495    impl Respond for GauntletResponder {
10496        fn respond(&self, req: &Request) -> ResponseTemplate {
10497            let body = body_json(req);
10498            let has_marker = messages_contain(&body, SUMMARY_PREFIX);
10499            if !body["stream"].as_bool().unwrap_or(false) {
10500                self.log
10501                    .lock()
10502                    .unwrap()
10503                    .push((has_marker, body_message_tokens(&body)));
10504            }
10505            if messages_contain(&body, "earlier tool-call messages omitted") {
10506                self.old_placeholder_seen.store(true, Ordering::SeqCst);
10507            }
10508            if has_marker {
10509                if messages_contain(&body, TASK) {
10510                    self.task_in_marker_request.store(true, Ordering::SeqCst);
10511                }
10512                let summary_needle = if self.static_marker_instead {
10513                    "Summary generation was unavailable."
10514                } else {
10515                    CANNED_SUMMARY
10516                };
10517                if messages_contain(&body, summary_needle) {
10518                    self.summary_in_marker_request.store(true, Ordering::SeqCst);
10519                }
10520                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10521                    "message": { "content": self.final_answer }
10522                }));
10523            }
10524            if body.get("tools").is_some() {
10525                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10526                    "message": { "content": "", "tool_calls": [{
10527                        "function": { "name": "read_file", "arguments": { "path": "big.txt" } }
10528                    }]}
10529                }))
10530            } else {
10531                ResponseTemplate::new(200)
10532                    .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
10533            }
10534        }
10535    }
10536
10537    /// THE B5 acceptance property: compression fires on a long tool-heavy
10538    /// conversation and the original task text still reaches the next
10539    /// request — summarized, not discarded.
10540    #[tokio::test]
10541    async fn active_task_survives_compression() {
10542        let server = MockServer::start().await;
10543        let log = Arc::new(Mutex::new(Vec::new()));
10544        let task_in_marker = Arc::new(AtomicBool::new(false));
10545        let summary_in_marker = Arc::new(AtomicBool::new(false));
10546        let old_placeholder = Arc::new(AtomicBool::new(false));
10547        Mock::given(method("POST"))
10548            .and(path("/api/chat"))
10549            .respond_with(GauntletResponder {
10550                final_answer: "the marker is GAUNTLET-7f3d9c".into(),
10551                log: log.clone(),
10552                task_in_marker_request: task_in_marker.clone(),
10553                summary_in_marker_request: summary_in_marker.clone(),
10554                old_placeholder_seen: old_placeholder.clone(),
10555                static_marker_instead: false,
10556            })
10557            .mount(&server)
10558            .await;
10559
10560        let ws = gauntlet_workspace();
10561        let workspace = ws.path().to_string_lossy().to_string();
10562        let messages = msgs();
10563        let caveats = Caveats::top();
10564        let uri = server.uri();
10565        let prompts = Arc::new(Mutex::new(Vec::new()));
10566        let summarizer = canned_summarizer(prompts.clone());
10567        let mut compress_state = CompressState::new();
10568        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10569        c.summarizer = Some(&*summarizer);
10570        c.compress_state = Some(&mut compress_state);
10571        let (reply, _streamed, _usage, hallu) = chat_complete(c, &mut NoMcp)
10572            .await
10573            .expect("chat_complete should succeed");
10574
10575        // The turn completed with a real answer, not a cap/diagnostic exit.
10576        assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
10577        assert_eq!(hallu, 0);
10578
10579        // The summarizer ran exactly once and its request carried the
10580        // original task verbatim (the verbatim-Active-Task anchor).
10581        let prompts = prompts.lock().unwrap();
10582        assert_eq!(prompts.len(), 1, "one compression, one summary request");
10583        assert!(
10584            prompts[0].contains(TASK),
10585            "summary request must quote the task verbatim"
10586        );
10587
10588        // The post-compression request still carried the task AND the
10589        // summary, wrapped in the marker — summarize, don't discard.
10590        assert!(task_in_marker.load(Ordering::SeqCst), "B5 property");
10591        assert!(summary_in_marker.load(Ordering::SeqCst));
10592        assert!(
10593            !old_placeholder.load(Ordering::SeqCst),
10594            "the old amputation placeholder must never be dispatched"
10595        );
10596
10597        // Reclaim numbers: the compressed request must be materially smaller
10598        // than the largest pre-compression request.
10599        let log = log.lock().unwrap();
10600        let before = log
10601            .iter()
10602            .filter(|(m, _)| !m)
10603            .map(|&(_, t)| t)
10604            .max()
10605            .expect("pre-compression requests were dispatched");
10606        let after = log
10607            .iter()
10608            .find(|(m, _)| *m)
10609            .map(|&(_, t)| t)
10610            .expect("a compressed request was dispatched");
10611        println!("e2e reclaim: ~{before} -> ~{after} est. message tokens");
10612        assert!(
10613            after < before * 6 / 10,
10614            "compression must reclaim >40% here (got {before} -> {after})"
10615        );
10616    }
10617
10618    /// THE B6 regression (#282): a FIRST-turn request on a fresh capability
10619    /// cache (no `max_ok_input`, no `safe_context`) whose history exceeds the
10620    /// `num_ctx` ceiling must compress BEFORE dispatch and dispatch under the
10621    /// ceiling. Pre-fix, `send_budget` was `None` here — the after-benchmark
10622    /// measured all 10 B6 runs shipping ~41k-token requests into a forced
10623    /// 4,096 window with zero compression events (8/10 silently wrong),
10624    /// because the `num_ctx` newt itself sent fed into nothing.
10625    #[tokio::test]
10626    async fn first_turn_over_num_ctx_ceiling_compresses_before_dispatch() {
10627        let server = MockServer::start().await;
10628        let log = Arc::new(Mutex::new(Vec::new()));
10629        let task_in_marker = Arc::new(AtomicBool::new(false));
10630        let summary_in_marker = Arc::new(AtomicBool::new(false));
10631        let old_placeholder = Arc::new(AtomicBool::new(false));
10632        Mock::given(method("POST"))
10633            .and(path("/api/chat"))
10634            .respond_with(GauntletResponder {
10635                final_answer: "the marker is GAUNTLET-7f3d9c".into(),
10636                log: log.clone(),
10637                task_in_marker_request: task_in_marker.clone(),
10638                summary_in_marker_request: summary_in_marker.clone(),
10639                old_placeholder_seen: old_placeholder.clone(),
10640                static_marker_instead: false,
10641            })
10642            .mount(&server)
10643            .await;
10644
10645        // The B6 shape, condensed: turn 1 of a fresh process already carries
10646        // a history far over the forced window (a restored conversation whose
10647        // assistant replies dumped file contents) — ~34k chars ≈ 9k estimated
10648        // tokens against a 4,096 num_ctx. The task itself is small and sits
10649        // up front (the protected head), the bulk is summarizable middle, and
10650        // the recent tail is small — compression CAN reach the budget here,
10651        // so a still-over-budget dispatch would be a wiring failure, not an
10652        // incompressibility artifact.
10653        let filler = "the quick brown newt reads three fifty-kilobyte fixtures\n".repeat(50);
10654        let mut messages = vec![MemMessage::system("you are a test"), MemMessage::user(TASK)];
10655        for _ in 0..12 {
10656            messages.push(MemMessage::assistant(format!("file contents: {filler}")));
10657            messages.push(MemMessage::user("continue"));
10658        }
10659
10660        let ws = gauntlet_workspace();
10661        let workspace = ws.path().to_string_lossy().to_string();
10662        let caveats = Caveats::top();
10663        let uri = server.uri();
10664        let prompts = Arc::new(Mutex::new(Vec::new()));
10665        let summarizer = canned_summarizer(prompts.clone());
10666        let mut compress_state = CompressState::new();
10667        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10668        // First turn of a fresh session: NO capability-cache numbers, NO
10669        // token threshold — pre-#282 nothing armed the trigger. The only
10670        // ceiling in play is the num_ctx the loop itself is about to send.
10671        c.max_ok_input = None;
10672        c.safe_context = None;
10673        c.mid_loop_trim_tokens = None;
10674        c.num_ctx = Some(4_096);
10675        c.summarizer = Some(&*summarizer);
10676        c.compress_state = Some(&mut compress_state);
10677        let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
10678            .await
10679            .expect("the first turn must complete");
10680
10681        // The turn produced the real answer (visibly degraded, not silently
10682        // wrong: compression ran and the model answered from the summary).
10683        assert_eq!(reply, "the marker is GAUNTLET-7f3d9c");
10684
10685        // Compression fired BEFORE the first dispatch: the summarizer ran,
10686        // and the VERY FIRST request the backend ever saw already carried
10687        // the compaction marker. Step 24.4 (#559): this ~34k-char middle now
10688        // exceeds the per-request cap, so the ONE compression event issues
10689        // several BOUNDED chunk + reduce summary requests instead of a single
10690        // truncated one — assert ≥1 (the before-dispatch guarantee is the
10691        // `first_had_marker` check below), not exactly one.
10692        assert!(
10693            !prompts.lock().unwrap().is_empty(),
10694            "compression ran before the first dispatch (≥1 bounded summary request)"
10695        );
10696        let log = log.lock().unwrap();
10697        let (first_had_marker, first_tokens) =
10698            *log.first().expect("at least one request dispatched");
10699        assert!(
10700            first_had_marker,
10701            "B6: the first dispatched request must already be compressed — \
10702             pre-#282 it went out raw at ~9k tokens"
10703        );
10704
10705        // And it dispatched UNDER the ceiling: 80% of 4,096 = 3,276 input
10706        // tokens (the same reply headroom the probe math reserves).
10707        assert!(
10708            first_tokens <= 3_276,
10709            "first dispatch must fit the num_ctx input ceiling \
10710             (got ~{first_tokens} est. message tokens > 3,276)"
10711        );
10712
10713        // Summarize-don't-discard still holds on the turn-1 path.
10714        assert!(task_in_marker.load(Ordering::SeqCst), "task survives");
10715        assert!(summary_in_marker.load(Ordering::SeqCst), "summary present");
10716        assert!(!old_placeholder.load(Ordering::SeqCst));
10717    }
10718
10719    /// Summarizer endpoint returns 500 → the static marker is dispatched
10720    /// instead and the turn still completes (never aborts).
10721    #[tokio::test]
10722    async fn summarizer_500_degrades_to_static_marker_and_turn_completes() {
10723        let server = MockServer::start().await;
10724        Mock::given(method("POST"))
10725            .and(path("/summarize"))
10726            .respond_with(ResponseTemplate::new(500).set_body_string("boom"))
10727            .mount(&server)
10728            .await;
10729        let log = Arc::new(Mutex::new(Vec::new()));
10730        let task_in_marker = Arc::new(AtomicBool::new(false));
10731        let static_in_marker = Arc::new(AtomicBool::new(false));
10732        let old_placeholder = Arc::new(AtomicBool::new(false));
10733        Mock::given(method("POST"))
10734            .and(path("/api/chat"))
10735            .respond_with(GauntletResponder {
10736                final_answer: "completed despite summarizer outage".into(),
10737                log: log.clone(),
10738                task_in_marker_request: task_in_marker.clone(),
10739                summary_in_marker_request: static_in_marker.clone(),
10740                old_placeholder_seen: old_placeholder.clone(),
10741                static_marker_instead: true,
10742            })
10743            .mount(&server)
10744            .await;
10745
10746        // A summarizer that really performs the HTTP call — and gets a 500.
10747        let attempts = Arc::new(AtomicUsize::new(0));
10748        let summarize_url = format!("{}/summarize", server.uri());
10749        let attempts_in = attempts.clone();
10750        let summarizer: Summarizer = Box::new(move |prompt: String| {
10751            let url = summarize_url.clone();
10752            let attempts = attempts_in.clone();
10753            Box::pin(async move {
10754                attempts.fetch_add(1, Ordering::SeqCst);
10755                let resp = reqwest::Client::new()
10756                    .post(&url)
10757                    .body(prompt)
10758                    .send()
10759                    .await?;
10760                if !resp.status().is_success() {
10761                    anyhow::bail!("summarizer endpoint {}", resp.status());
10762                }
10763                Ok(resp.text().await?)
10764            })
10765        });
10766
10767        let ws = gauntlet_workspace();
10768        let workspace = ws.path().to_string_lossy().to_string();
10769        let messages = msgs();
10770        let caveats = Caveats::top();
10771        let uri = server.uri();
10772        let mut compress_state = CompressState::new();
10773        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10774        c.summarizer = Some(&*summarizer);
10775        c.compress_state = Some(&mut compress_state);
10776        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10777            .await
10778            .expect("a summarizer failure must never abort the turn");
10779
10780        assert_eq!(reply, "completed despite summarizer outage");
10781        assert!(
10782            attempts.load(Ordering::SeqCst) >= 1,
10783            "the summarizer endpoint must have been attempted"
10784        );
10785        assert!(
10786            static_in_marker.load(Ordering::SeqCst),
10787            "the static fallback marker must reach the model"
10788        );
10789        assert!(task_in_marker.load(Ordering::SeqCst), "task still anchored");
10790        assert!(!old_placeholder.load(Ordering::SeqCst));
10791    }
10792
10793    /// OpenAI-path mirror: the same pipeline serves the second loop — the
10794    /// marker + anchored task reach the post-compression request.
10795    struct OpenAiGauntletResponder {
10796        final_answer: String,
10797        task_in_marker_request: Arc<AtomicBool>,
10798        summary_in_marker_request: Arc<AtomicBool>,
10799        directive_in_marker_request: Arc<AtomicBool>,
10800    }
10801
10802    impl Respond for OpenAiGauntletResponder {
10803        fn respond(&self, req: &Request) -> ResponseTemplate {
10804            let body = body_json(req);
10805            if messages_contain(&body, SUMMARY_PREFIX) {
10806                if messages_contain(&body, TASK) {
10807                    self.task_in_marker_request.store(true, Ordering::SeqCst);
10808                }
10809                if messages_contain(&body, CANNED_SUMMARY) {
10810                    self.summary_in_marker_request.store(true, Ordering::SeqCst);
10811                }
10812                if messages_contain(&body, compress::CONTINUATION_PREFIX) {
10813                    self.directive_in_marker_request
10814                        .store(true, Ordering::SeqCst);
10815                }
10816                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
10817                    "choices": [{ "message": { "content": self.final_answer } }]
10818                }));
10819            }
10820            if body.get("tools").is_some() {
10821                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10822                    "choices": [{ "message": {
10823                        "content": null,
10824                        "tool_calls": [{
10825                            "id": "call_1",
10826                            "type": "function",
10827                            "function": { "name": "read_file", "arguments": "{\"path\":\"big.txt\"}" }
10828                        }]
10829                    }}]
10830                }))
10831            } else {
10832                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10833                    "choices": [{ "message": { "content": "cap exit" } }]
10834                }))
10835            }
10836        }
10837    }
10838
10839    #[tokio::test]
10840    async fn openai_loop_compresses_with_the_same_pipeline() {
10841        let server = MockServer::start().await;
10842        let task_in_marker = Arc::new(AtomicBool::new(false));
10843        let summary_in_marker = Arc::new(AtomicBool::new(false));
10844        let directive_in_marker = Arc::new(AtomicBool::new(false));
10845        Mock::given(method("POST"))
10846            .and(path("/v1/chat/completions"))
10847            .respond_with(OpenAiGauntletResponder {
10848                final_answer: "openai: marker is GAUNTLET-7f3d9c".into(),
10849                task_in_marker_request: task_in_marker.clone(),
10850                summary_in_marker_request: summary_in_marker.clone(),
10851                directive_in_marker_request: directive_in_marker.clone(),
10852            })
10853            .mount(&server)
10854            .await;
10855
10856        let ws = gauntlet_workspace();
10857        let workspace = ws.path().to_string_lossy().to_string();
10858        let messages = msgs();
10859        let caveats = Caveats::top();
10860        let uri = server.uri();
10861        let prompts = Arc::new(Mutex::new(Vec::new()));
10862        let summarizer = canned_summarizer(prompts.clone());
10863        let mut compress_state = CompressState::new();
10864        let mut c = ctx(&uri, &messages, &caveats, &workspace);
10865        c.kind = BackendKind::Openai;
10866        c.api_key = Some("sk-test");
10867        c.summarizer = Some(&*summarizer);
10868        c.compress_state = Some(&mut compress_state);
10869        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10870            .await
10871            .expect("openai loop should succeed");
10872
10873        assert_eq!(reply, "openai: marker is GAUNTLET-7f3d9c");
10874        assert!(!prompts.lock().unwrap().is_empty(), "summarizer engaged");
10875        assert!(task_in_marker.load(Ordering::SeqCst));
10876        assert!(summary_in_marker.load(Ordering::SeqCst));
10877        // This compaction fired MID-TURN (tool rounds preceded it), so the
10878        // real pipeline outcome must also carry the act-now continuation
10879        // directive to the wire (commit 2's seam, exercised end to end).
10880        assert!(
10881            directive_in_marker.load(Ordering::SeqCst),
10882            "the post-compaction act-now directive must reach the wire"
10883        );
10884    }
10885
10886    // -----------------------------------------------------------------------
10887    // Multi-compression long-haul regressions (review of PR #267, F1/F2/N3):
10888    // the original suite never exercised a SECOND compression — the gap that
10889    // let the self-poisoning boundary bug through.
10890    // -----------------------------------------------------------------------
10891
10892    /// Per-request long-haul observations: `(dispatched message count,
10893    /// length of the last tool-role message — the freshest result)`.
10894    type HaulLog = Arc<Mutex<Vec<(usize, Option<usize>)>>>;
10895
10896    /// Endless-work responder: each round calls a (hallucinated) write-ish
10897    /// tool and then `read_file` of `path` while tools are offered (the loop
10898    /// runs to its round cap), answering only the cap-exit tools-disabled
10899    /// completion. The non-read-only call keeps the loop's read-only nudge
10900    /// quiet — no intervening user messages, the regime the reviewer's F1
10901    /// traces locked up in (a periodic nudge would hand the boundary a fresh
10902    /// anchor and mask the bug). Logs, per tool-offering request, the
10903    /// dispatched message count and the length of the LAST tool-role message
10904    /// — the freshest result the model is about to read.
10905    struct LongHaulResponder {
10906        path: &'static str,
10907        log: HaulLog,
10908    }
10909
10910    impl Respond for LongHaulResponder {
10911        fn respond(&self, req: &Request) -> ResponseTemplate {
10912            let body = body_json(req);
10913            if body.get("tools").is_some() {
10914                let empty = Vec::new();
10915                let msgs = body["messages"].as_array().unwrap_or(&empty);
10916                let last_tool_len = msgs
10917                    .iter()
10918                    .rev()
10919                    .find(|m| m["role"].as_str() == Some("tool"))
10920                    .and_then(|m| m["content"].as_str())
10921                    .map(|c| c.chars().count());
10922                self.log.lock().unwrap().push((msgs.len(), last_tool_len));
10923                ResponseTemplate::new(200).set_body_json(serde_json::json!({
10924                    "message": { "content": "", "tool_calls": [
10925                        { "function": { "name": "apply_patch", "arguments": {} } },
10926                        { "function": { "name": "read_file", "arguments": { "path": self.path } } }
10927                    ]}
10928                }))
10929            } else {
10930                ResponseTemplate::new(200).set_body_json(
10931                    serde_json::json!({ "message": { "content": "long haul done" } }),
10932                )
10933            }
10934        }
10935    }
10936
10937    /// Three prior turns, then the active task — the reviewer's multi-turn
10938    /// shape (the last REAL user message sits deep before the tool rounds).
10939    fn multi_turn_msgs() -> Vec<MemMessage> {
10940        vec![
10941            MemMessage::system("you are a test"),
10942            MemMessage::user("prior turn: inspect the workspace"),
10943            MemMessage::assistant("inspected — looks healthy"),
10944            MemMessage::user("prior turn: run the linters"),
10945            MemMessage::assistant("linters are green"),
10946            MemMessage::user("prior turn: sketch a fix"),
10947            MemMessage::assistant("sketched in my head"),
10948            MemMessage::user(TASK),
10949        ]
10950    }
10951
10952    /// Drive `rounds` tool rounds under count-only compression pressure.
10953    /// Returns `(per-request log, summarizer invocations, reply, latched)`.
10954    async fn run_long_haul(
10955        mem_messages: Vec<MemMessage>,
10956        rounds: usize,
10957        threshold: usize,
10958        file: &'static str,
10959        content: &str,
10960    ) -> (Vec<(usize, Option<usize>)>, usize, String, bool) {
10961        let server = MockServer::start().await;
10962        let log = Arc::new(Mutex::new(Vec::new()));
10963        Mock::given(method("POST"))
10964            .and(path("/api/chat"))
10965            .respond_with(LongHaulResponder {
10966                path: file,
10967                log: log.clone(),
10968            })
10969            .mount(&server)
10970            .await;
10971        let ws = tempfile::TempDir::new().unwrap();
10972        std::fs::write(ws.path().join(file), content).unwrap();
10973        let workspace = ws.path().to_string_lossy().to_string();
10974        let caveats = Caveats::top();
10975        let uri = server.uri();
10976        let prompts = Arc::new(Mutex::new(Vec::new()));
10977        let summarizer = canned_summarizer(prompts.clone());
10978        let mut compress_state = CompressState::new();
10979        let mut c = ctx(&uri, &mem_messages, &caveats, &workspace);
10980        c.max_tool_rounds = rounds;
10981        c.mid_loop_trim_threshold = threshold;
10982        c.mid_loop_trim_tokens = None; // count-only: the F1/F2 regime
10983        c.summarizer = Some(&*summarizer);
10984        c.compress_state = Some(&mut compress_state);
10985        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
10986            .await
10987            .expect("the long haul must complete");
10988        let log = log.lock().unwrap().clone();
10989        let calls = prompts.lock().unwrap().len();
10990        (log, calls, reply, compress_state.is_disabled())
10991    }
10992
10993    /// F1 regression (i) — the reviewer's single-turn trace: 4 KB read_file
10994    /// results to round 40 under the count-only trigger. Pre-fix, the second
10995    /// compression anchored on its own summary: the count never shrank again
10996    /// (65 messages at round 40), the summarizer re-fired per round, and the
10997    /// fresh 4 KB result was one-lined before every dispatch from round ~20
10998    /// — the model could never read anything for the rest of the turn.
10999    #[tokio::test]
11000    async fn forty_rounds_single_turn_stay_bounded_with_fresh_results_intact() {
11001        let line = "the quick brown newt compresses context without discarding it\n";
11002        let threshold = 15usize;
11003        let (log, summarizer_calls, reply, latched) =
11004            run_long_haul(msgs(), 40, threshold, "big.txt", &line.repeat(64)).await;
11005
11006        assert_eq!(reply, "long haul done");
11007        assert!(!latched, "count-only pressure must never latch anti-thrash");
11008        assert_eq!(log.len(), 40, "all 40 tool rounds dispatched");
11009        for (round, (len, last_tool)) in log.iter().enumerate() {
11010            assert!(
11011                *len <= threshold + 6,
11012                "round {round}: dispatched {len} messages — the count must stay \
11013                 bounded after every compression (threshold {threshold} + slack)"
11014            );
11015            if let Some(n) = last_tool {
11016                assert!(
11017                    *n > 1_000,
11018                    "round {round}: the fresh tool result was destroyed before \
11019                     dispatch ({n} chars — a one-liner)"
11020                );
11021            }
11022        }
11023        assert!(summarizer_calls >= 2, "the long haul compresses repeatedly");
11024        assert!(
11025            summarizer_calls <= 16,
11026            "summarizer invocations must be bounded, not per-round \
11027             (got {summarizer_calls} in 40 rounds)"
11028        );
11029        let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
11030        println!(
11031            "forty-round trace: max dispatched len {max_len}, \
11032             summarizer calls {summarizer_calls}"
11033        );
11034    }
11035
11036    /// F1 regression (ii) — the reviewer's multi-turn trace: 3 prior turns,
11037    /// then 30 tool rounds. Pre-fix the regime locked in at round ~9 (the
11038    /// anchor pinned the current task, the middle shrank to the previous
11039    /// summary alone, nothing could ever shrink) and the summarizer re-ran
11040    /// almost every round (71 invocations in 80 rounds).
11041    #[tokio::test]
11042    async fn thirty_rounds_multi_turn_stay_bounded_with_fresh_results_intact() {
11043        let line = "the quick brown newt compresses context without discarding it\n";
11044        let threshold = 15usize;
11045        let (log, summarizer_calls, reply, latched) = run_long_haul(
11046            multi_turn_msgs(),
11047            30,
11048            threshold,
11049            "big.txt",
11050            &line.repeat(64),
11051        )
11052        .await;
11053
11054        assert_eq!(reply, "long haul done");
11055        assert!(!latched, "count-only pressure must never latch anti-thrash");
11056        assert_eq!(log.len(), 30, "all 30 tool rounds dispatched");
11057        for (round, (len, last_tool)) in log.iter().enumerate() {
11058            assert!(
11059                *len <= threshold + 6,
11060                "round {round}: dispatched {len} messages — bounded"
11061            );
11062            if let Some(n) = last_tool {
11063                assert!(
11064                    *n > 1_000,
11065                    "round {round}: fresh tool result destroyed pre-dispatch ({n} chars)"
11066                );
11067            }
11068        }
11069        assert!(summarizer_calls >= 2);
11070        assert!(
11071            summarizer_calls <= 14,
11072            "summarizer invocations must be bounded, not per-round \
11073             (got {summarizer_calls} in 30 rounds)"
11074        );
11075        let max_len = log.iter().map(|(l, _)| *l).max().unwrap();
11076        println!(
11077            "thirty-round multi-turn trace: max dispatched len {max_len}, \
11078             summarizer calls {summarizer_calls}"
11079        );
11080    }
11081
11082    /// F2 regression — the reviewer's 600-char-results multi-turn shape:
11083    /// count-only compressions whose per-pass reclaim is small must neither
11084    /// latch anti-thrash (silently killing the VRAM guard) nor escalate to
11085    /// the Refused bail — pre-fix this errored the whole turn at round 25
11086    /// with "context exceeds the model's input budget" while the actual
11087    /// context was ~3-5k tokens and NO token threshold was configured.
11088    #[tokio::test]
11089    async fn count_only_low_reclaim_never_latches_or_bails() {
11090        let (log, _calls, reply, latched) =
11091            run_long_haul(multi_turn_msgs(), 30, 15, "small.txt", &"x".repeat(600)).await;
11092        assert_eq!(reply, "long haul done", "the turn must complete — no bail");
11093        assert!(
11094            !latched,
11095            "count-only passes must never latch the disable switch"
11096        );
11097        assert_eq!(log.len(), 30, "all 30 rounds ran (no Refused early-exit)");
11098    }
11099
11100    // -----------------------------------------------------------------------
11101    // Trailing-group long-hauls (#270 / #285): the read-only-nudge regime the
11102    // #267 re-verifier flagged as untested, and the oversized-single-round
11103    // residual #284's gauntlet measured.
11104    // -----------------------------------------------------------------------
11105
11106    /// Per-request observations for the nudged haul: `(message count, nudge
11107    /// text present, per-result content lengths of the trailing tool group)`.
11108    type NudgedLog = Arc<Mutex<Vec<(usize, bool, Vec<usize>)>>>;
11109
11110    /// Read-only-only responder: every round reads big1 + big2 + small (three
11111    /// read-only calls, no writes), so the loop's read-only nudge fires every
11112    /// few rounds — the regime #270's probe ran in. The #267 long-haul
11113    /// responder deliberately added a write-ish call to keep that nudge quiet
11114    /// and hand the boundary no fresh anchors; this one deliberately does the
11115    /// opposite. Logs, per tool-offering request, the content length of every
11116    /// tool result in the trailing group (everything after the last
11117    /// assistant-with-`tool_calls`).
11118    struct NudgedHaulResponder {
11119        log: NudgedLog,
11120    }
11121
11122    impl Respond for NudgedHaulResponder {
11123        fn respond(&self, req: &Request) -> ResponseTemplate {
11124            let body = body_json(req);
11125            if body.get("tools").is_some() {
11126                let empty = Vec::new();
11127                let msgs = body["messages"].as_array().unwrap_or(&empty);
11128                let group_start = msgs
11129                    .iter()
11130                    .rposition(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()))
11131                    .map(|i| i + 1)
11132                    .unwrap_or(msgs.len());
11133                let group_lens: Vec<usize> = msgs[group_start..]
11134                    .iter()
11135                    .filter(|m| m["role"].as_str() == Some("tool"))
11136                    .filter_map(|m| m["content"].as_str())
11137                    .map(|c| c.chars().count())
11138                    .collect();
11139                let nudged = messages_contain(&body, "read-only rounds so far");
11140                self.log
11141                    .lock()
11142                    .unwrap()
11143                    .push((msgs.len(), nudged, group_lens));
11144                // `role` present like a real Ollama reply — the loop appends
11145                // this object verbatim and the prune pairing reads the role.
11146                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11147                    "message": { "role": "assistant", "content": "", "tool_calls": [
11148                        { "function": { "name": "read_file", "arguments": { "path": "big1.txt" } } },
11149                        { "function": { "name": "read_file", "arguments": { "path": "big2.txt" } } },
11150                        { "function": { "name": "read_file", "arguments": { "path": "small.txt" } } }
11151                    ]}
11152                }))
11153            } else {
11154                ResponseTemplate::new(200).set_body_json(
11155                    serde_json::json!({ "message": { "content": "nudged haul done" } }),
11156                )
11157            }
11158        }
11159    }
11160
11161    /// #270 e2e — the test the #267 re-verifier said was missing: a
11162    /// nudge-active long session (read-only rounds only, so the loop injects
11163    /// its stop-exploring user message right before the compression call
11164    /// site every few rounds) under a hard token trigger. Pre-fix, the
11165    /// nudge zeroed the trailing `role == "tool"` count, `keep_last`
11166    /// floored at 2, and BOTH big fresh results were one-lined pre-dispatch
11167    /// on every nudge round. Post-fix the group derives from the assistant
11168    /// turn that issued the calls: the newest member is always whole and
11169    /// the middle member survives every round (#285's within-group reclaim
11170    /// may one-line only the oldest, oldest-first, and only while over
11171    /// budget).
11172    #[tokio::test]
11173    async fn nudged_long_haul_keeps_fresh_group_results_intact() {
11174        let server = MockServer::start().await;
11175        let log: NudgedLog = Arc::new(Mutex::new(Vec::new()));
11176        Mock::given(method("POST"))
11177            .and(path("/api/chat"))
11178            .respond_with(NudgedHaulResponder { log: log.clone() })
11179            .mount(&server)
11180            .await;
11181        // Distinct contents per file: identical results would engage the
11182        // dedupe pass, which is not what this test pins.
11183        let ws = tempfile::TempDir::new().unwrap();
11184        std::fs::write(
11185            ws.path().join("big1.txt"),
11186            "the first big fixture keeps unseen results intact\n".repeat(200),
11187        )
11188        .unwrap();
11189        std::fs::write(
11190            ws.path().join("big2.txt"),
11191            "the second big fixture must survive every nudge round\n".repeat(200),
11192        )
11193        .unwrap();
11194        std::fs::write(
11195            ws.path().join("small.txt"),
11196            "the small newest fixture stays whole\n".repeat(5),
11197        )
11198        .unwrap();
11199        let workspace = ws.path().to_string_lossy().to_string();
11200        let messages = msgs();
11201        let caveats = Caveats::top();
11202        let uri = server.uri();
11203        let prompts = Arc::new(Mutex::new(Vec::new()));
11204        let summarizer = canned_summarizer(prompts.clone());
11205        let mut compress_state = CompressState::new();
11206        let mut c = ctx(&uri, &messages, &caveats, &workspace);
11207        c.max_tool_rounds = 12;
11208        c.mid_loop_trim_tokens = Some(4_000); // hard trigger, fires most rounds
11209        c.summarizer = Some(&*summarizer);
11210        c.compress_state = Some(&mut compress_state);
11211        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11212            .await
11213            .expect("the nudged haul must complete");
11214
11215        assert_eq!(reply, "nudged haul done");
11216        assert!(
11217            !compress_state.is_disabled(),
11218            "real reclaims every round must never latch anti-thrash"
11219        );
11220        let log = log.lock().unwrap();
11221        for (round, entry) in log.iter().enumerate() {
11222            println!("nudged haul round {round}: {entry:?}");
11223        }
11224        assert_eq!(log.len(), 12, "all 12 tool rounds dispatched");
11225        let nudged_rounds = log.iter().filter(|(_, n, _)| *n).count();
11226        assert!(
11227            nudged_rounds >= 2,
11228            "the read-only nudge regime must actually be active \
11229             (got {nudged_rounds} nudged requests)"
11230        );
11231        for (round, (len, nudged, group_lens)) in log.iter().enumerate() {
11232            if group_lens.is_empty() {
11233                continue; // round 0: no tool group yet
11234            }
11235            assert_eq!(group_lens.len(), 3, "round {round}: pairing intact");
11236            // The newest member is ALWAYS whole.
11237            assert!(
11238                group_lens[2] > 150,
11239                "round {round} (nudged={nudged}): the newest fresh result \
11240                 was destroyed pre-dispatch ({} chars)",
11241                group_lens[2]
11242            );
11243            // The middle member survives too: within-group reclaim is
11244            // oldest-first and stops at the budget — pre-#270 every nudge
11245            // round one-lined it ({len} msgs dispatched).
11246            assert!(
11247                group_lens[1] > 1_000,
11248                "round {round} (nudged={nudged}, {len} msgs): the middle \
11249                 fresh result was destroyed pre-dispatch ({} chars)",
11250                group_lens[1]
11251            );
11252        }
11253        let max_tokens = log.iter().map(|(l, _, _)| *l).max().unwrap();
11254        println!(
11255            "nudged haul trace: {nudged_rounds} nudged rounds, \
11256             max dispatched len {max_tokens}, group lens e.g. {:?}",
11257            log.last().unwrap().2
11258        );
11259    }
11260
11261    /// Per-request observations for the oversized-round haul: `(est message
11262    /// tokens, a one-lined, b one-lined, c payload intact, task present)`.
11263    type OversizedLog = Arc<Mutex<Vec<(usize, bool, bool, bool, bool)>>>;
11264
11265    /// One round reads three files whose results TOGETHER exceed the model
11266    /// window; answers as soon as the dispatch shows a.txt one-lined.
11267    struct OversizedRoundResponder {
11268        log: OversizedLog,
11269    }
11270
11271    impl Respond for OversizedRoundResponder {
11272        fn respond(&self, req: &Request) -> ResponseTemplate {
11273            let body = body_json(req);
11274            let a_onelined = messages_contain(&body, "[read_file] read 'a.txt'");
11275            if !body["stream"].as_bool().unwrap_or(false) {
11276                self.log.lock().unwrap().push((
11277                    body_message_tokens(&body),
11278                    a_onelined,
11279                    messages_contain(&body, "[read_file] read 'b.txt'"),
11280                    messages_contain(&body, "NEWEST-PAYLOAD-C-INTACT"),
11281                    messages_contain(&body, TASK),
11282                ));
11283            }
11284            if a_onelined {
11285                return ResponseTemplate::new(200).set_body_json(serde_json::json!({
11286                    "message": { "content": "the three files are summarized" }
11287                }));
11288            }
11289            if body.get("tools").is_some() {
11290                // `role` present like a real Ollama reply — the prune
11291                // pairing needs it to name the file in each one-liner.
11292                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11293                    "message": { "role": "assistant", "content": "", "tool_calls": [
11294                        { "function": { "name": "read_file", "arguments": { "path": "a.txt" } } },
11295                        { "function": { "name": "read_file", "arguments": { "path": "b.txt" } } },
11296                        { "function": { "name": "read_file", "arguments": { "path": "c.txt" } } }
11297                    ]}
11298                }))
11299            } else {
11300                ResponseTemplate::new(200)
11301                    .set_body_json(serde_json::json!({ "message": { "content": "cap exit" } }))
11302            }
11303        }
11304    }
11305
11306    /// #285 e2e — the B6 residual measured in #284's gauntlet: ONE round's
11307    /// tool group alone exceeds the `num_ctx` ceiling (the only budget in
11308    /// play on a fresh capability cache, #282/#284 wiring untouched).
11309    /// Pre-fix the F1c protection made the group unreclaimable: compression
11310    /// honestly reported "still over budget" and the dispatch shipped
11311    /// over-window into a silent backend truncation. Post-fix the dispatch
11312    /// fits the ceiling: a.txt / b.txt one-lined (each naming its file for
11313    /// re-read), c.txt — the newest — intact, the task still present, and
11314    /// the model returns the real answer.
11315    #[tokio::test]
11316    async fn oversized_single_round_dispatches_within_the_window() {
11317        let server = MockServer::start().await;
11318        let log: OversizedLog = Arc::new(Mutex::new(Vec::new()));
11319        Mock::given(method("POST"))
11320            .and(path("/api/chat"))
11321            .respond_with(OversizedRoundResponder { log: log.clone() })
11322            .mount(&server)
11323            .await;
11324        // Three ~7 KB results: together ~5.3k est tokens against a 4,096
11325        // num_ctx (3,276-token input ceiling) — the trailing group ALONE
11326        // exceeds the window; no boundary can split it (B6's shape).
11327        // Distinct contents per file: identical results would engage the
11328        // dedupe pass, which is not what this test pins.
11329        let ws = tempfile::TempDir::new().unwrap();
11330        std::fs::write(
11331            ws.path().join("a.txt"),
11332            "fixture a arrives first in the one giant trailing group\n".repeat(125),
11333        )
11334        .unwrap();
11335        std::fs::write(
11336            ws.path().join("b.txt"),
11337            "fixture b arrives second and is older than the newest\n".repeat(130),
11338        )
11339        .unwrap();
11340        std::fs::write(
11341            ws.path().join("c.txt"),
11342            format!(
11343                "{}NEWEST-PAYLOAD-C-INTACT\n",
11344                "fixture c arrives last and must reach the model whole\n".repeat(130)
11345            ),
11346        )
11347        .unwrap();
11348        let workspace = ws.path().to_string_lossy().to_string();
11349        let messages = msgs();
11350        let caveats = Caveats::top();
11351        let uri = server.uri();
11352        let prompts = Arc::new(Mutex::new(Vec::new()));
11353        let summarizer = canned_summarizer(prompts.clone());
11354        let mut compress_state = CompressState::new();
11355        let mut c = ctx(&uri, &messages, &caveats, &workspace);
11356        // Fresh capability cache, no token threshold: the num_ctx the loop
11357        // itself sends is the only ceiling (#284's regime, untouched).
11358        c.max_ok_input = None;
11359        c.safe_context = None;
11360        c.mid_loop_trim_tokens = None;
11361        c.num_ctx = Some(4_096);
11362        c.summarizer = Some(&*summarizer);
11363        c.compress_state = Some(&mut compress_state);
11364        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11365            .await
11366            .expect("the oversized round must complete");
11367
11368        let log = log.lock().unwrap();
11369        for (i, entry) in log.iter().enumerate() {
11370            println!("oversized round request {i}: {entry:?}");
11371        }
11372        // Never a silent wrong answer: the model answered from real data.
11373        assert_eq!(reply, "the three files are summarized");
11374
11375        // THE #285 property: no dispatch ships over the window. The newest
11376        // result alone fits the ceiling here, so there is no truthful
11377        // still-over residual to excuse an oversized request — pre-fix the
11378        // round-1 dispatch went out at ~5.5k est tokens against 3,276.
11379        for (i, &(tokens, ..)) in log.iter().enumerate() {
11380            assert!(
11381                tokens <= 3_276,
11382                "request {i} dispatched over the window: ~{tokens} est. \
11383                 message tokens > 3,276 (the pre-#285 B6 residual)"
11384            );
11385        }
11386
11387        let (tokens, _, b_onelined, c_intact, task_present) = *log
11388            .iter()
11389            .find(|(_, a, ..)| *a)
11390            .expect("a dispatch with a.txt one-lined must have happened");
11391        assert!(
11392            b_onelined,
11393            "older members one-lined in order: b.txt one-liner missing"
11394        );
11395        assert!(
11396            c_intact,
11397            "#285: the NEWEST result must reach the model whole"
11398        );
11399        assert!(task_present, "the task survives the within-group reclaim");
11400        // The dispatch fits the same input ceiling the #284 test pins:
11401        // 80% of 4,096 = 3,276 estimated message tokens.
11402        assert!(
11403            tokens <= 3_276,
11404            "#285: the reclaimed dispatch must fit the window \
11405             (got ~{tokens} est. message tokens > 3,276)"
11406        );
11407        println!(
11408            "#285 e2e trace: reclaimed dispatch ~{tokens} est. tokens \
11409             (ceiling 3,276), a/b one-lined, c intact"
11410        );
11411    }
11412
11413    /// N3 — the loop-level refusal path end-to-end: a HARD token trigger
11414    /// (`mid_loop_trim_tokens`) over a genuinely incompressible context
11415    /// records two poor passes, latches anti-thrash, and the next
11416    /// over-budget round refuses the dispatch: `chat_complete` errors with
11417    /// the named message. Post-F2 only token-over-budget sessions can reach
11418    /// this — the bail text is now truthful.
11419    #[tokio::test]
11420    async fn hard_budget_thrash_latches_then_bails_with_named_error() {
11421        let server = MockServer::start().await;
11422        let log = Arc::new(Mutex::new(Vec::new()));
11423        Mock::given(method("POST"))
11424            .and(path("/api/chat"))
11425            .respond_with(LongHaulResponder {
11426                path: "tiny.txt",
11427                log: log.clone(),
11428            })
11429            .mount(&server)
11430            .await;
11431        let ws = tempfile::TempDir::new().unwrap();
11432        std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
11433        let workspace = ws.path().to_string_lossy().to_string();
11434        // Incompressible: the system prompt dominates and no structural
11435        // pass or boundary can reduce it.
11436        let messages = vec![
11437            MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
11438            MemMessage::user(TASK),
11439        ];
11440        let caveats = Caveats::top();
11441        let uri = server.uri();
11442        let mut compress_state = CompressState::new();
11443        let mut c = ctx(&uri, &messages, &caveats, &workspace);
11444        c.mid_loop_trim_tokens = Some(50); // hard trigger, unreachable budget
11445        c.compress_state = Some(&mut compress_state);
11446        let err = chat_complete(c, &mut NoMcp)
11447            .await
11448            .expect_err("the post-latch over-budget round must refuse the send");
11449        let msg = err.to_string();
11450        assert!(msg.contains("exceeds the model's input budget"), "{msg}");
11451        assert!(msg.contains("auto-compression is disabled"), "{msg}");
11452        assert!(compress_state.is_disabled(), "anti-thrash latched first");
11453        assert!(
11454            log.lock().unwrap().len() <= 3,
11455            "the refusal must stop the loop within a round or two"
11456        );
11457    }
11458
11459    /// Step 20.3 — the loop-level fail-open path (the gpt-4.1 bug). Same
11460    /// incompressible over-budget shape as the bail test, but the budget rests
11461    /// on the proven-good high-water mark ALONE (`max_ok_input`, no
11462    /// `safe_context`, no `num_ctx`, no token threshold) — the cloud /
11463    /// no-`/api/show` case. Anti-thrash still latches, but the latched
11464    /// over-budget rounds must NOT refuse: refusing here is the death spiral
11465    /// the user hit. The loop keeps dispatching (fails open) and never bails
11466    /// with the named error.
11467    #[tokio::test]
11468    async fn lone_hwm_budget_fails_open_and_does_not_bail() {
11469        let server = MockServer::start().await;
11470        let log = Arc::new(Mutex::new(Vec::new()));
11471        Mock::given(method("POST"))
11472            .and(path("/api/chat"))
11473            .respond_with(LongHaulResponder {
11474                path: "tiny.txt",
11475                log: log.clone(),
11476            })
11477            .mount(&server)
11478            .await;
11479        let ws = tempfile::TempDir::new().unwrap();
11480        std::fs::write(ws.path().join("tiny.txt"), "ok").unwrap();
11481        let workspace = ws.path().to_string_lossy().to_string();
11482        let messages = vec![
11483            MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
11484            MemMessage::user(TASK),
11485        ];
11486        let caveats = Caveats::top();
11487        let uri = server.uri();
11488        let mut compress_state = CompressState::new();
11489        let mut c = ctx(&uri, &messages, &caveats, &workspace);
11490        // The cloud shape: a starved proven-good HWM and NOTHING authoritative.
11491        c.mid_loop_trim_tokens = None;
11492        c.max_ok_input = Some(50); // largest prompt "seen" — a floor, not a cap
11493        c.safe_context = None; // no /api/show seed
11494        c.num_ctx = None; // no per-request window ceiling
11495        c.compress_state = Some(&mut compress_state);
11496        let result = chat_complete(c, &mut NoMcp).await;
11497        // The session must NOT die on the budget bail — it fails open.
11498        if let Err(e) = &result {
11499            let msg = e.to_string();
11500            assert!(
11501                !msg.contains("exceeds the model's input budget"),
11502                "a lone-HWM budget must never refuse the send: {msg}"
11503            );
11504        }
11505        assert!(
11506            compress_state.is_disabled(),
11507            "anti-thrash still latches on the poor passes"
11508        );
11509        assert!(
11510            log.lock().unwrap().len() > 3,
11511            "the loop kept dispatching past the latch instead of bailing"
11512        );
11513    }
11514}
11515
11516// ---------------------------------------------------------------------------
11517// Per-round observation hook + mid-turn budget raise (Phase 20,
11518// docs/design/model-self-tuning.md §2.2) — wiremock e2e against both gates.
11519// ---------------------------------------------------------------------------
11520
11521#[cfg(test)]
11522mod observation_hook_tests {
11523    use super::*;
11524    use crate::caveats::Caveats;
11525    use crate::{BackendKind, MemMessage};
11526    use std::sync::atomic::{AtomicUsize, Ordering};
11527    use std::sync::Arc;
11528    use wiremock::matchers::{method, path};
11529    use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
11530
11531    fn ctx<'a>(
11532        server_uri: &'a str,
11533        messages: &'a [MemMessage],
11534        caveats: &'a Caveats,
11535    ) -> ChatCtx<'a> {
11536        ChatCtx {
11537            url: server_uri,
11538            model: "test-model",
11539            kind: BackendKind::Ollama,
11540            api_key: None,
11541            messages,
11542            task: "do the thing",
11543            workspace: ".",
11544            color: false,
11545            markdown: false,
11546            tool_offload: false,
11547            spill_store: None,
11548            compaction_store: None,
11549            scratchpad: false,
11550            scratchpad_store: None,
11551            code_search: None,
11552            experience_store: None,
11553            step_ledger: None,
11554            caveats,
11555            persona_tools: None,
11556            max_tool_rounds: 8,
11557            narration_nudge_cap: 1,
11558            workflow_grace_rounds: 0,
11559            tool_output_lines: 20,
11560            debug: false,
11561            trace: false,
11562            num_ctx: None,
11563            input_ceiling_pct: 80,
11564            low_budget_pct: 15,
11565            connect_timeout_secs: 5,
11566            inference_timeout_secs: 30,
11567            mid_loop_trim_threshold: 40,
11568            mid_loop_trim_tokens: None,
11569            max_ok_input: None,
11570            build_check_cmd: None,
11571            safe_context: None,
11572            recover_cw_400: None,
11573            note_sink: None,
11574            note_nudge: None,
11575            recall_source: None,
11576            memory_source: None,
11577            summarizer: None,
11578            compress_state: None,
11579            tool_events: None,
11580            phantom_reaches: None,
11581            end_reason: None,
11582            permission_gate: None,
11583            on_round_usage: None,
11584            estimate_ratio: None,
11585            estimation: crate::tokens::TokenEstimation::default(),
11586            summary_input_cap_floor_chars: 8_192,
11587            // #307: test ChatCtx carries no preset exec floor (headless default).
11588            exec_floor: None,
11589            write_ledger: None,
11590            cancel: None,
11591            git_tool: None,
11592            crew_runner: None,
11593        }
11594    }
11595
11596    fn body_json(req: &Request) -> serde_json::Value {
11597        serde_json::from_slice(&req.body).unwrap_or_default()
11598    }
11599
11600    fn is_stream(req: &Request) -> bool {
11601        body_json(req)["stream"].as_bool().unwrap_or(false)
11602    }
11603
11604    fn ndjson(lines: &[serde_json::Value]) -> ResponseTemplate {
11605        let body: String = lines
11606            .iter()
11607            .map(|l| format!("{l}\n"))
11608            .collect::<Vec<_>>()
11609            .join("");
11610        ResponseTemplate::new(200).set_body_raw(body.into_bytes(), "application/x-ndjson")
11611    }
11612
11613    /// Tool calls for the first two tools-offering requests (each reporting
11614    /// the backend ACCEPTED an 8,734-token prompt), then a final answer.
11615    struct AcceptsLargePrompts {
11616        tools_rounds: Arc<AtomicUsize>,
11617    }
11618    impl Respond for AcceptsLargePrompts {
11619        fn respond(&self, req: &Request) -> ResponseTemplate {
11620            if is_stream(req) {
11621                return ndjson(&[serde_json::json!({
11622                    "message": {"content": "budget raised, here is the answer"},
11623                    "done": true, "prompt_eval_count": 8_700, "eval_count": 12
11624                })]);
11625            }
11626            let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
11627            if n < 2 {
11628                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11629                    "message": {"content": "", "tool_calls": [{
11630                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11631                    }]},
11632                    "prompt_eval_count": 8_734, "eval_count": 10,
11633                }))
11634            } else {
11635                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11636                    "message": {"content": "budget raised, here is the answer"},
11637                    "prompt_eval_count": 8_700, "eval_count": 12,
11638                }))
11639            }
11640        }
11641    }
11642
11643    /// THE trace-class regression (the motivating failure): a poisoned-low
11644    /// `max_ok_input` (the largest prompt SEEN, not accepted) used to refuse
11645    /// sends the backend was happily evaluating. Now: the over-budget
11646    /// acceptance (a) reaches the caller as an `Accepted` observation with
11647    /// the backend's real prompt size, and (b) raises the in-turn send
11648    /// budget, so the turn completes instead of latching anti-thrash into
11649    /// the Refused bail across the following rounds.
11650    #[tokio::test]
11651    async fn poisoned_low_budget_recovers_via_accepted_observation_and_raise() {
11652        let server = MockServer::start().await;
11653        let tools_rounds = Arc::new(AtomicUsize::new(0));
11654        Mock::given(method("POST"))
11655            .and(path("/api/chat"))
11656            .respond_with(AcceptsLargePrompts {
11657                tools_rounds: tools_rounds.clone(),
11658            })
11659            .mount(&server)
11660            .await;
11661
11662        // A task big enough (~12k chars ≈ 3k est. tokens) to sit over the
11663        // poisoned 2,000-token budget but far under what the backend accepts.
11664        let big_task = "study the workspace and report. ".repeat(380);
11665        let messages = vec![
11666            MemMessage::system("you are a test"),
11667            MemMessage::user(&big_task),
11668        ];
11669        let caveats = Caveats::top();
11670        let uri = server.uri();
11671        let mut observations: Vec<RoundObservation> = Vec::new();
11672        let mut hook = |obs: RoundObservation| observations.push(obs);
11673        let mut c = ctx(&uri, &messages, &caveats);
11674        c.max_ok_input = Some(2_000); // the poisoned ratchet
11675        c.on_round_usage = Some(&mut hook);
11676        let (reply, _streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
11677            .await
11678            .expect("the turn must complete — no Refused bail after the raise");
11679
11680        assert_eq!(reply, "budget raised, here is the answer");
11681        assert!(
11682            observations.iter().any(|o| matches!(
11683                o,
11684                RoundObservation::Accepted {
11685                    prompt_tokens: 8_734,
11686                    ..
11687                }
11688            )),
11689            "the accepted 8,734-token prompt must reach the hook: {observations:?}"
11690        );
11691        // Every accepted round carried a non-zero chars/4 estimate for
11692        // calibration pairing.
11693        for o in &observations {
11694            if let RoundObservation::Accepted {
11695                estimated_tokens, ..
11696            } = o
11697            {
11698                assert!(*estimated_tokens > 0, "estimate rides along: {o:?}");
11699            }
11700        }
11701    }
11702
11703    /// Always tool calls (with usage) — drives the anti-thrash latch under an
11704    /// unreachable hard token budget so the turn ends in the Refused Err.
11705    struct ToolCallsWithUsage;
11706    impl Respond for ToolCallsWithUsage {
11707        fn respond(&self, req: &Request) -> ResponseTemplate {
11708            if body_json(req).get("tools").is_some() {
11709                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11710                    "message": {"content": "", "tool_calls": [{
11711                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11712                    }]},
11713                    "prompt_eval_count": 100, "eval_count": 5,
11714                }))
11715            } else {
11716                ResponseTemplate::new(200)
11717                    .set_body_json(serde_json::json!({"message": {"content": "cap exit"}}))
11718            }
11719        }
11720    }
11721
11722    /// A turn that ends `Err` (the Refused bail) STILL delivered the earlier
11723    /// rounds' `Accepted` observations first — evidence at the moment of
11724    /// observation, not in an epilogue the error skips (the spec's headline
11725    /// property). Also pins the bail's new tail: it names the model and the
11726    /// `newt tunings reset` escape hatch.
11727    #[tokio::test]
11728    async fn err_turn_still_delivered_accepted_observations_first() {
11729        let server = MockServer::start().await;
11730        Mock::given(method("POST"))
11731            .and(path("/api/chat"))
11732            .respond_with(ToolCallsWithUsage)
11733            .mount(&server)
11734            .await;
11735
11736        // Incompressible context + unreachable hard token budget → two poor
11737        // passes latch anti-thrash, the next round refuses the send.
11738        let messages = vec![
11739            MemMessage::system(format!("you are a test. {}", "rule. ".repeat(700))),
11740            MemMessage::user("do the thing"),
11741        ];
11742        let caveats = Caveats::top();
11743        let uri = server.uri();
11744        let mut compress_state = CompressState::new();
11745        let mut observations: Vec<RoundObservation> = Vec::new();
11746        let mut hook = |obs: RoundObservation| observations.push(obs);
11747        let mut c = ctx(&uri, &messages, &caveats);
11748        c.mid_loop_trim_tokens = Some(50);
11749        c.compress_state = Some(&mut compress_state);
11750        c.on_round_usage = Some(&mut hook);
11751        let err = chat_complete(c, &mut NoMcp)
11752            .await
11753            .expect_err("the post-latch over-budget round must refuse the send");
11754
11755        let msg = err.to_string();
11756        assert!(msg.contains("exceeds the model's input budget"), "{msg}");
11757        assert!(
11758            msg.contains("newt tunings reset test-model"),
11759            "the bail must name the model's reset command: {msg}"
11760        );
11761        assert!(
11762            observations.iter().any(|o| matches!(
11763                o,
11764                RoundObservation::Accepted {
11765                    prompt_tokens: 100,
11766                    ..
11767                }
11768            )),
11769            "accepted rounds before the bail must have been reported: {observations:?}"
11770        );
11771    }
11772
11773    /// Probe 1: thinking-only (empty content, non-empty `thinking`, generated
11774    /// tokens); the corrective retry then recovers. The hook must see exactly
11775    /// one `ThinkingOnly` (once per turn) plus the recovery's `Accepted`.
11776    struct ThinkingOnlyThenRecover {
11777        probes: Arc<AtomicUsize>,
11778    }
11779    impl Respond for ThinkingOnlyThenRecover {
11780        fn respond(&self, req: &Request) -> ResponseTemplate {
11781            if is_stream(req) {
11782                if self.probes.load(Ordering::SeqCst) <= 1 {
11783                    ndjson(&[serde_json::json!({
11784                        "message": {"content": ""}, "done": true,
11785                        "prompt_eval_count": 9, "eval_count": 4
11786                    })])
11787                } else {
11788                    ndjson(&[serde_json::json!({
11789                        "message": {"content": "recovered after thinking-only"},
11790                        "done": true, "prompt_eval_count": 12, "eval_count": 3
11791                    })])
11792                }
11793            } else {
11794                let n = self.probes.fetch_add(1, Ordering::SeqCst) + 1;
11795                if n == 1 {
11796                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
11797                        "message": {
11798                            "content": "",
11799                            "thinking": "all reasoning, no final text"
11800                        },
11801                        "prompt_eval_count": 10, "eval_count": 2559,
11802                    }))
11803                } else {
11804                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
11805                        "message": {"content": "recovered after thinking-only"},
11806                        "prompt_eval_count": 12, "eval_count": 3,
11807                    }))
11808                }
11809            }
11810        }
11811    }
11812
11813    #[tokio::test]
11814    async fn thinking_only_response_emits_one_thinking_only_observation() {
11815        let server = MockServer::start().await;
11816        let probes = Arc::new(AtomicUsize::new(0));
11817        Mock::given(method("POST"))
11818            .and(path("/api/chat"))
11819            .respond_with(ThinkingOnlyThenRecover {
11820                probes: probes.clone(),
11821            })
11822            .mount(&server)
11823            .await;
11824
11825        let messages = vec![
11826            MemMessage::system("you are a test"),
11827            MemMessage::user("do the thing"),
11828        ];
11829        let caveats = Caveats::top();
11830        let uri = server.uri();
11831        let mut observations: Vec<RoundObservation> = Vec::new();
11832        let mut hook = |obs: RoundObservation| observations.push(obs);
11833        let mut c = ctx(&uri, &messages, &caveats);
11834        c.on_round_usage = Some(&mut hook);
11835        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11836            .await
11837            .expect("the corrective retry recovers the turn");
11838
11839        assert_eq!(reply, "recovered after thinking-only");
11840        let thinking = observations
11841            .iter()
11842            .filter(|o| matches!(o, RoundObservation::ThinkingOnly))
11843            .count();
11844        assert_eq!(thinking, 1, "exactly once per turn: {observations:?}");
11845        assert!(
11846            observations
11847                .iter()
11848                .any(|o| matches!(o, RoundObservation::Accepted { .. })),
11849            "the recovered round is usable output: {observations:?}"
11850        );
11851    }
11852
11853    /// Tool round + final round both reporting a prompt at ≥95% of the
11854    /// request's `num_ctx` — Ollama may have silently dropped the head, so
11855    /// the rounds are window evidence of NOTHING: no `Accepted` observation,
11856    /// no budget raise.
11857    struct TruncationSuspectResponder {
11858        tools_rounds: Arc<AtomicUsize>,
11859    }
11860    impl Respond for TruncationSuspectResponder {
11861        fn respond(&self, req: &Request) -> ResponseTemplate {
11862            if is_stream(req) {
11863                return ndjson(&[serde_json::json!({
11864                    "message": {"content": "suspect answer"}, "done": true,
11865                    "prompt_eval_count": 4_000, "eval_count": 5
11866                })]);
11867            }
11868            let n = self.tools_rounds.fetch_add(1, Ordering::SeqCst);
11869            if n == 0 {
11870                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11871                    "message": {"content": "", "tool_calls": [{
11872                        "function": {"name": "definitely_not_a_real_tool", "arguments": {}}
11873                    }]},
11874                    // 4,000 ≥ 95% of 4,096 (3,891) — truncation suspect.
11875                    "prompt_eval_count": 4_000, "eval_count": 5,
11876                }))
11877            } else {
11878                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11879                    "message": {"content": "suspect answer"},
11880                    "prompt_eval_count": 4_000, "eval_count": 5,
11881                }))
11882            }
11883        }
11884    }
11885
11886    #[tokio::test]
11887    async fn truncation_suspect_rounds_emit_nothing() {
11888        let server = MockServer::start().await;
11889        let tools_rounds = Arc::new(AtomicUsize::new(0));
11890        Mock::given(method("POST"))
11891            .and(path("/api/chat"))
11892            .respond_with(TruncationSuspectResponder {
11893                tools_rounds: tools_rounds.clone(),
11894            })
11895            .mount(&server)
11896            .await;
11897
11898        let messages = vec![
11899            MemMessage::system("you are a test"),
11900            MemMessage::user("do the thing"),
11901        ];
11902        let caveats = Caveats::top();
11903        let uri = server.uri();
11904        let mut observations: Vec<RoundObservation> = Vec::new();
11905        let mut hook = |obs: RoundObservation| observations.push(obs);
11906        let mut c = ctx(&uri, &messages, &caveats);
11907        c.num_ctx = Some(4_096);
11908        c.on_round_usage = Some(&mut hook);
11909        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11910            .await
11911            .expect("suspect rounds still complete the turn");
11912
11913        assert_eq!(reply, "suspect answer");
11914        assert!(
11915            observations.is_empty(),
11916            "a possibly head-truncated prompt is evidence of nothing: \
11917             {observations:?}"
11918        );
11919    }
11920
11921    /// OpenAI-path mirror: tool round then final content, both with usage —
11922    /// the hook receives `Accepted` for both (no `num_ctx` on this wire, so
11923    /// no truncation gate), and an absent hook stays a no-op.
11924    struct OpenAiAcceptsResponder;
11925    impl Respond for OpenAiAcceptsResponder {
11926        fn respond(&self, req: &Request) -> ResponseTemplate {
11927            if body_json(req).get("tools").is_some()
11928                && !body_json(req)["messages"]
11929                    .as_array()
11930                    .map(|m| m.iter().any(|x| x["role"] == "tool"))
11931                    .unwrap_or(false)
11932            {
11933                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11934                    "choices": [{"message": {
11935                        "content": null,
11936                        "tool_calls": [{
11937                            "id": "call_1",
11938                            "type": "function",
11939                            "function": {"name": "definitely_not_a_real_tool", "arguments": "{}"}
11940                        }]
11941                    }}],
11942                    "usage": {"prompt_tokens": 5_120, "completion_tokens": 9},
11943                }))
11944            } else {
11945                ResponseTemplate::new(200).set_body_json(serde_json::json!({
11946                    "choices": [{"message": {"content": "openai accepted"}}],
11947                    "usage": {"prompt_tokens": 5_200, "completion_tokens": 11},
11948                }))
11949            }
11950        }
11951    }
11952
11953    #[tokio::test]
11954    async fn openai_loop_reports_accepted_rounds() {
11955        let server = MockServer::start().await;
11956        Mock::given(method("POST"))
11957            .and(path("/v1/chat/completions"))
11958            .respond_with(OpenAiAcceptsResponder)
11959            .mount(&server)
11960            .await;
11961
11962        let messages = vec![
11963            MemMessage::system("you are a test"),
11964            MemMessage::user("do the thing"),
11965        ];
11966        let caveats = Caveats::top();
11967        let uri = server.uri();
11968        let mut observations: Vec<RoundObservation> = Vec::new();
11969        let mut hook = |obs: RoundObservation| observations.push(obs);
11970        let mut c = ctx(&uri, &messages, &caveats);
11971        c.kind = BackendKind::Openai;
11972        c.api_key = Some("sk-test");
11973        c.on_round_usage = Some(&mut hook);
11974        let (reply, _, _, _) = chat_complete(c, &mut NoMcp)
11975            .await
11976            .expect("openai loop should succeed");
11977
11978        assert_eq!(reply, "openai accepted");
11979        let accepted: Vec<u32> = observations
11980            .iter()
11981            .filter_map(|o| match o {
11982                RoundObservation::Accepted { prompt_tokens, .. } => Some(*prompt_tokens),
11983                _ => None,
11984            })
11985            .collect();
11986        assert_eq!(
11987            accepted,
11988            vec![5_120, 5_200],
11989            "both usable rounds reported, in order: {observations:?}"
11990        );
11991    }
11992
11993    /// Persistent empties (probe AND stream return empty content, no tool
11994    /// calls) at a prompt ≥85% of the configured `safe_context`, with no
11995    /// generated tokens — so the suspicious-empty corrective retry is NOT
11996    /// taken (that path needs `eval_count > 0`). The loop exhausts its two
11997    /// `overflow_retries`, then on the next persistent empty falls through to
11998    /// the silent-overflow exit and must emit exactly one
11999    /// `SuspectedOverflow { prompt_tokens }` carrying the merged (largest
12000    /// single) prompt size — the loop-emission seam that the dispatch-seam
12001    /// `record_overflow` tests at probe.rs cannot reach.
12002    struct PersistentEmptyOverflow;
12003    impl Respond for PersistentEmptyOverflow {
12004        fn respond(&self, _req: &Request) -> ResponseTemplate {
12005            if is_stream(_req) {
12006                // Stream re-issue: empty content, no tokens generated, but the
12007                // round still reports a large evaluated prompt.
12008                return ndjson(&[serde_json::json!({
12009                    "message": {"content": ""}, "done": true,
12010                    "prompt_eval_count": 8_734, "eval_count": 0
12011                })]);
12012            }
12013            // Probe (non-stream): empty content, no tool calls, no generated
12014            // tokens, large evaluated prompt.
12015            ResponseTemplate::new(200).set_body_json(serde_json::json!({
12016                "message": {"content": ""},
12017                "prompt_eval_count": 8_734, "eval_count": 0,
12018            }))
12019        }
12020    }
12021
12022    #[tokio::test]
12023    async fn persistent_empty_over_safe_context_emits_suspected_overflow() {
12024        let server = MockServer::start().await;
12025        Mock::given(method("POST"))
12026            .and(path("/api/chat"))
12027            .respond_with(PersistentEmptyOverflow)
12028            .mount(&server)
12029            .await;
12030
12031        let messages = vec![
12032            MemMessage::system("you are a test"),
12033            MemMessage::user("do the thing"),
12034        ];
12035        let caveats = Caveats::top();
12036        let uri = server.uri();
12037        let mut observations: Vec<RoundObservation> = Vec::new();
12038        let mut hook = |obs: RoundObservation| observations.push(obs);
12039        let mut c = ctx(&uri, &messages, &caveats);
12040        // 8_734 ≥ 85% of 4_000 (3_400) → the silent-overflow gate fires.
12041        c.safe_context = Some(4_000);
12042        c.on_round_usage = Some(&mut hook);
12043        let (_reply, streamed, _usage, _hallu) = chat_complete(c, &mut NoMcp)
12044            .await
12045            .expect("persistent empties return the empty-response message, not Err");
12046
12047        // Diagnostic exit returns non-streamed placeholder text.
12048        assert!(
12049            !streamed,
12050            "the silent-overflow exit is not a streamed reply"
12051        );
12052        // Exactly one SuspectedOverflow, carrying the merged (largest single)
12053        // prompt size — emitted once at the exit, never per retry.
12054        let overflow: Vec<u32> = observations
12055            .iter()
12056            .filter_map(|o| match o {
12057                RoundObservation::SuspectedOverflow { prompt_tokens } => Some(*prompt_tokens),
12058                _ => None,
12059            })
12060            .collect();
12061        assert_eq!(
12062            overflow,
12063            vec![8_734],
12064            "one SuspectedOverflow at the merged prompt size: {observations:?}"
12065        );
12066        // No Accepted: empty content is never usable output, so the window
12067        // evidence must not ratchet a success.
12068        assert!(
12069            !observations
12070                .iter()
12071                .any(|o| matches!(o, RoundObservation::Accepted { .. })),
12072            "empty rounds are not Accepted evidence: {observations:?}"
12073        );
12074    }
12075}