Skip to main content

car_server_core/assistant/
agent_loop.rs

1//! The assistant agent loop: propose → validate → execute → observe.
2//!
3//! One multi-turn tool-use conversation, driven by CAR inference and executed
4//! through a [`Runtime`] (validator + policy + permission tiers + event log)
5//! whose tool executor is the [`GeneralExecutor`]. The same loop backs the
6//! one-shot CLI, the REPL, and — per turn — the `agent.chat` surface; streaming
7//! is decoupled through a synchronous `emit` sink so a caller can forward events
8//! to stdout or to `agent.chat.event` notifications without the loop knowing.
9//!
10//! [`Runtime`]: car_engine::Runtime
11//! [`GeneralExecutor`]: super::executor::GeneralExecutor
12
13use car_engine::{builtin_tool_labels, format_tool_result, tool_output_is_external, Runtime};
14use car_inference::tasks::generate::{ContentBlock, Message, Provenance, ToolCall};
15use car_inference::{GenerateParams, GenerateRequest, InferenceResult};
16use car_ir::{ActionProposal, ActionStatus};
17use serde_json::{json, Value};
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::Duration;
21
22use super::memory::MemoryTools;
23use super::tool_memory::{approach_from_call, FailureSignature, ToolMemory, RECOVERY_WINDOW_TURNS};
24use crate::coder::native_loop::{AssistantGenerateError, TurnGenerator};
25
26/// Upper bound (bytes) on a single tool observation fed back into context, so a
27/// large read/output can't blow the request size. Truncates on a char boundary.
28///
29/// Public because it is the threshold the whole `value_store_previews` A/B
30/// turns on: below it both arms render identically, so a benchmark suite whose
31/// observations all fit measures nothing. `car-bench`'s harness suite has a
32/// guard test asserting its held-out split still contains a task that clears
33/// this number, and that test has to read the real constant — a copy of `16 *
34/// 1024` in the bench would go stale the day this one moved, and the A/B would
35/// go quietly inert again.
36pub const OBSERVATION_CAP: usize = 16 * 1024;
37
38/// The shipped default for [`AssistantConfig::value_store_previews`] — the one
39/// place any production call site reads it from, so "what does CAR do out of the
40/// box" is a single greppable fact rather than a literal repeated at the four
41/// production construction sites (`car do` twice, the MCP assistant, and coder
42/// discussions), which can drift apart. The remaining hard-coded `false`s in the
43/// workspace are deliberate and stay: eight test fixtures in `chat.rs`, this
44/// module's own off-arm fixture, and `HarnessBenchConfig`, whose `false` names
45/// the *truncating arm* of the A/B rather than a product setting.
46///
47/// `no_production_call_site_hard_codes_the_preview_default` (here, and its twin
48/// in `car-cli`) is what keeps that true — pinning the constant's value alone
49/// would let a re-hardcoded literal reinstate the old default with every test
50/// still green.
51///
52/// **`false`, confirmed by the measured A/B on 2026-08-27**, which is what
53/// Parslee-ai/car#813 asked for before this shape changed. Three paired
54/// replicates of `car-bench-harness --split all` (29 tasks, model
55/// `anthropic/claude-sonnet-5`, seed 0), off arm versus `--value-store-previews`:
56///
57/// | mean over 3 reps | off (truncating) | on (retained previews) |
58/// |---|---|---|
59/// | pass rate | 0.967 | **1.000** |
60/// | model calls / task | 2.235 | 2.407 (**+7.7%**) |
61/// | tokens / task | 14,467.7 | **10,073.1 (−30.4%)** |
62///
63/// **#813's stated trigger did not fire:** it asked for fewer model calls at
64/// equal-or-better pass rate without a token blow-up, and calls rose 7.7% while
65/// tokens fell 30.4%. The run also did not count actual `$rN` resolutions, so it
66/// cannot attribute the extra calls to handle use rather than repeated tools.
67/// With one model, three replicates, fixed arm order, and substantial variance
68/// on byte-identical below-cap tasks, the pass-rate and token results do not
69/// justify overriding the calls criterion. The conservative measured decision
70/// is therefore to keep the product default off and improve/re-measure the
71/// preview format before any wider rollout.
72///
73/// The measurement, caveats, and how to re-run it:
74/// `docs/solutions/value-store-previews-ab-2026-08-27.md`.
75pub const VALUE_STORE_PREVIEWS_DEFAULT: bool = false;
76
77/// Streamed events from one loop run. `emit` is called synchronously as the loop
78/// progresses; a chat caller forwards these to `agent.chat.event`, a CLI caller
79/// prints them.
80pub enum AssistantEvent {
81    /// A model call is about to begin. Emitted before awaiting the generator so
82    /// a host can distinguish live inference from a wedged assistant loop.
83    InferenceStarted {
84        model: String,
85        attempt: u32,
86        turn: u32,
87    },
88    /// A transient provider failure will be retried after a bounded backoff.
89    InferenceRetry {
90        model: String,
91        attempt: u32,
92        reason: String,
93        backoff_ms: u64,
94    },
95    /// Attribution for one completed model turn. Emitted before that turn's
96    /// text/tool events so every rendered answer can be tied to its actual
97    /// serving model, including transparent on-device degradation.
98    ModelServed {
99        model_id: String,
100        local_last_resort: bool,
101    },
102    /// The model's free-text for a turn (may be empty when it only calls tools).
103    Text(String),
104    /// A tool is about to run. `call_id` is generated by the loop rather than
105    /// trusted from the provider, and `sequence` is its one-based position in
106    /// this turn. The matching result carries both values.
107    ToolCall {
108        call_id: String,
109        sequence: u32,
110        name: String,
111        params: Value,
112    },
113    /// A tool finished. `ok` is false for a failed/denied/cancelled call.
114    ToolResult {
115        call_id: String,
116        sequence: u32,
117        name: String,
118        ok: bool,
119        /// The uncapped observation. Every external projection MUST redact and
120        /// bound this before emitting it; the model transcript gets a separate
121        /// capped copy.
122        content: String,
123    },
124    /// Terminal: the model answered with no further tool calls.
125    Done { text: String },
126    /// Terminal: the run failed (inference/transport error).
127    Error(String),
128    /// Terminal: the turn cannot reach Parslee inference because of the
129    /// ACCOUNT, and says which remedy applies.
130    ///
131    /// Typed apart from [`AssistantEvent::Error`] because the two ask
132    /// different things of a host. An `error` is something that went wrong; an
133    /// `auth_required` is a step the person can take, and a host can render it
134    /// as a sign-in card rather than a red row. It is terminal in the same
135    /// sense `Done`/`Error` are — the chat service sends it INSTEAD of the
136    /// `done`/`error` frame, never before one.
137    AuthRequired {
138        reason: AuthRequiredReason,
139        /// Host-facing. Names the remedy in plain language.
140        message: String,
141    },
142    /// Goal-loop verifier result after one iteration. This surfaces CAR's
143    /// grounded completion evidence to CLI/chat hosts instead of hiding it in
144    /// tracing logs.
145    GoalEvaluated {
146        iteration: u32,
147        met: bool,
148        grounded: bool,
149        reason: String,
150    },
151}
152
153/// Why an out-of-the-box turn could not reach Parslee inference.
154///
155/// The wire spellings are `signed_out` | `expired` | `no_workspace`; a host
156/// branches on these, not on the message.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum AuthRequiredReason {
159    /// Nobody is signed in.
160    SignedOut,
161    /// **Reserved.** Nothing in production emits this yet: a stale credential
162    /// record still resolves, so the bearer is sent and Parslee's 401 comes
163    /// back untyped. The reason exists so hosts can render it (they already
164    /// treat it like `signed_out`) and so the mapping is in place when those
165    /// 401 sites are typed. Covered by a constructed-value test only — there
166    /// is deliberately no claim here that a real turn reaches it.
167    Expired,
168    /// Signed in, but the account has no workspace yet.
169    NoWorkspace,
170}
171
172impl AuthRequiredReason {
173    /// The wire spelling.
174    pub fn as_str(self) -> &'static str {
175        match self {
176            Self::SignedOut => "signed_out",
177            Self::Expired => "expired",
178            Self::NoWorkspace => "no_workspace",
179        }
180    }
181
182    /// The host-facing remedy for this reason — the exact wire `message`.
183    ///
184    /// Not the provider's own text: the typed detail behind it reads like
185    /// `no credential for proprietary provider 'parslee' (model
186    /// parslee/advisor): …`, which is the right thing in a log and the wrong
187    /// thing on a card.
188    pub fn remedy(self) -> &'static str {
189        match self {
190            Self::SignedOut => AUTH_REQUIRED_SIGNED_OUT_MESSAGE,
191            Self::Expired => AUTH_REQUIRED_EXPIRED_MESSAGE,
192            Self::NoWorkspace => AUTH_REQUIRED_NO_WORKSPACE_MESSAGE,
193        }
194    }
195}
196
197/// The wire `message` for `auth_required { reason: "signed_out" }`.
198///
199/// Owner-approved copy (Keenan, relayed 2026-09-16) and byte-exact on purpose:
200/// CarHost pins these same bytes in its fixtures, so the daemon and the card
201/// cannot drift. The closing sentence is the interim path for a brand-new
202/// account — the platform does not yet bounce a zero-membership sign-in into
203/// onboarding, so a person with no account has to create one first. Do not
204/// paraphrase it.
205pub const AUTH_REQUIRED_SIGNED_OUT_MESSAGE: &str =
206    "Parslee Core runs on your Parslee account. Sign in to continue. New to Parslee? \
207     Create your account at parslee.ai first, then come back and sign in.";
208
209/// The wire `message` for `auth_required { reason: "expired" }` (reserved —
210/// see [`AuthRequiredReason::Expired`]).
211pub const AUTH_REQUIRED_EXPIRED_MESSAGE: &str =
212    "Your Parslee sign-in has expired. Sign in again to continue.";
213
214/// The wire `message` for `auth_required { reason: "no_workspace" }`.
215pub const AUTH_REQUIRED_NO_WORKSPACE_MESSAGE: &str =
216    "Your Parslee account has no workspace yet. Finish setting up at parslee.ai, then try again.";
217
218/// Map a typed generation failure to the terminal `auth_required` reason, or
219/// `None` when the turn keeps today's `error`.
220///
221/// Parslee only, and only for reasons a person can actually resolve by
222/// attending to their account:
223///
224/// - `StoreUnreadable` is the machine's keychain refusing to open. Signing in
225///   again does not unlock it, and a sign-in card would be wrong advice.
226/// - `EnvVarMissing` is a provider key the operator sets, not a Parslee
227///   session.
228/// - `RaceRetryable` means the authority re-read found a credential; it is a
229///   retry signal, not evidence anybody must do anything.
230/// - Another provider's `SignedOut` is that provider's problem; the Parslee
231///   sign-in card would not repair it.
232/// Machine-readable reason an assistant loop failed after it had started.
233///
234/// This is deliberately narrower than [`AssistantGenerateError`]: the latter
235/// carries provider/account detail used while deciding how a turn ends, while
236/// this type is the stable terminal receipt a caller branches on.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
238#[serde(tag = "cause", rename_all = "snake_case")]
239pub enum AssistantFailureCause {
240    /// A remote 5xx/429/transport failure exhausted its bounded retry budget.
241    TransientInference { status: Option<u16> },
242    /// Inference ended for another non-authentication reason.
243    Inference,
244}
245
246impl AssistantFailureCause {
247    /// Human rendering paired with the machine-readable `cause` spelling.
248    pub fn label(self) -> &'static str {
249        match self {
250            Self::TransientInference { .. } => "transient inference failure",
251            Self::Inference => "inference failure",
252        }
253    }
254}
255
256fn generation_failure_cause(error: &AssistantGenerateError) -> AssistantFailureCause {
257    match error {
258        AssistantGenerateError::Transient { status, .. } => {
259            AssistantFailureCause::TransientInference { status: *status }
260        }
261        AssistantGenerateError::CredentialUnavailable { .. }
262        | AssistantGenerateError::WorkspaceRequired { .. }
263        | AssistantGenerateError::Other(_) => AssistantFailureCause::Inference,
264    }
265}
266
267fn auth_required_reason(error: &AssistantGenerateError) -> Option<AuthRequiredReason> {
268    fn is_parslee(provider: &str) -> bool {
269        provider.eq_ignore_ascii_case("parslee")
270    }
271    match error {
272        AssistantGenerateError::CredentialUnavailable {
273            provider, reason, ..
274        } if is_parslee(provider) => match reason {
275            car_inference::CredentialFailure::SignedOut => Some(AuthRequiredReason::SignedOut),
276            car_inference::CredentialFailure::Expired { .. } => Some(AuthRequiredReason::Expired),
277            car_inference::CredentialFailure::StoreUnreadable
278            | car_inference::CredentialFailure::EnvVarMissing { .. }
279            | car_inference::CredentialFailure::RaceRetryable => None,
280        },
281        AssistantGenerateError::WorkspaceRequired { provider, .. } if is_parslee(provider) => {
282            Some(AuthRequiredReason::NoWorkspace)
283        }
284        _ => None,
285    }
286}
287
288/// Static configuration for a loop run.
289#[derive(Clone)]
290pub struct AssistantConfig {
291    /// Model id, or `None` to let the router choose (pin a tool-capable model
292    /// for real tool use — the local completion path ignores tools).
293    pub model: Option<String>,
294    /// Fail on the selected model rather than silently substituting another.
295    /// Native chat enables this only for an explicit per-turn selection.
296    pub strict_model: bool,
297    /// Hard cap on loop turns.
298    pub max_turns: u32,
299    /// The model-visible tool list (from `GeneralExecutor::all_tool_defs()`).
300    pub tools: Vec<Value>,
301    /// Tool names that require human approval before running (the standing tier
302    /// doesn't auto-allow them, e.g. writes/shell on the local host without
303    /// `--full-access`). Empty when the tier auto-allows everything.
304    pub gated_tools: Vec<String>,
305    /// Optional per-agent approval policy. Given a tool name + params, returns
306    /// whether to allow, require approval, or deny — the runtime enforcement of
307    /// the `agent_permissions.*` posture for the running agent. When set it
308    /// takes precedence over `gated_tools`; when `None`, `gated_tools` (the
309    /// standing-tier list) applies, so existing callers are unchanged.
310    pub approval_policy: Option<ApprovalPolicyFn>,
311    /// Host-side proactive memory bank for the assistant loop. When present, CAR
312    /// runs a deterministic memory-maintenance + selective-intervention pass
313    /// before each model turn, so long-running agents do not depend on the model
314    /// remembering to call `recall` at the right time.
315    pub proactive_memory: Option<Arc<MemoryTools>>,
316    /// Durable learned tool repairs. When present, the loop recalls what
317    /// recovered this kind of tool failure last time, and records what recovers
318    /// one this time — the difference between an agent that remembers and one
319    /// that gets better (see [`super::tool_memory`]).
320    ///
321    /// `None` disables both halves. A surface that must be reproducible — the
322    /// benchmark harness above all — leaves it unset deliberately: a run whose
323    /// prompt depends on what the operator's assistant learned last Tuesday is
324    /// not a measurement.
325    pub tool_memory: Option<Arc<ToolMemory>>,
326    /// Information-flow tool labels used to classify whether a tool result came
327    /// from outside the trust boundary (car#723).
328    ///
329    /// `None` falls back to [`car_engine::builtin_tool_labels`], so the
330    /// network-reaching commodity tools are always classified even when a caller
331    /// supplies nothing. Deliberately `Option<_>` rather than a plain map with a
332    /// `Default`: an empty map would silently classify everything as internal,
333    /// and a security marking that a forgotten field can switch off is not one.
334    /// Callers that load `.car/tool-labels.json` should pass the merged map so a
335    /// project's own `trust: untrusted` declarations are honoured here too.
336    pub tool_labels: Option<HashMap<String, car_verify::infoflow::ToolLabels>>,
337    /// The run's task list, rendered into a per-turn state block at the tail of
338    /// the request (Parslee-ai/car#814 items 2-3). `None` renders no block.
339    pub todos: Option<Arc<tokio::sync::Mutex<super::todo::TodoList>>>,
340    /// Retain tool results for the run and put a typed bounded preview in the
341    /// transcript, instead of destructively truncating (Parslee-ai/car#813).
342    ///
343    /// Production call sites pass [`VALUE_STORE_PREVIEWS_DEFAULT`], which is
344    /// `false` because the measured A/B did not meet #813's fewer-model-calls
345    /// criterion. See that constant for the results and caveats.
346    ///
347    /// Still a field rather than a constant read inside the loop, because the
348    /// bench needs both arms in one binary and a caller may want the old shape.
349    /// With this `false` the observation path is byte-for-byte what it was:
350    /// `cap()`, same cap, same notice.
351    ///
352    /// Below [`OBSERVATION_CAP`] the two arms *render* identically — the flag
353    /// can only change an observation that crosses the cap. Note the flag also
354    /// gates `SessionValues::resolve_refs` on every tool call, so once a handle
355    /// exists a later below-cap call whose argument is a `$rN` reference is
356    /// rewritten on the on arm only. That is a no-op until something over the
357    /// cap has been retained; it is not the same statement as "nothing below
358    /// the cap can ever differ".
359    pub value_store_previews: bool,
360    /// Constrain the run's FINAL answer to JSON. Applies to the answer, not
361    /// to the work: it is NOT sent on any turn that offers tools, because a
362    /// JSON-constrained request suppresses tool use on real providers (GLM
363    /// 5.3 Flash answered in one turn without a single tool call under
364    /// `json_object`; the same goal unconstrained ran two delegations and two
365    /// reads and got it right). The loop instead checks the final
366    /// no-tool-call answer itself and, only if it is not the requested shape,
367    /// re-asks ONCE with no tools and `response_format` set — see
368    /// [`final_text_matches_format`] and the repair branch. A run that offers
369    /// no tools at all has nothing to suppress and carries the format on
370    /// every turn. A final answer that already parses costs no extra call.
371    ///
372    /// `None` is the correct default for every caller that does not consume
373    /// the answer as data. A knob that tightens the output contract, never a
374    /// toggle between implementations (CLAUDE.md rule 1a). Provider-dependent:
375    /// the Anthropic protocol rejects it up front (`UnsupportedMode`).
376    pub response_format: Option<car_inference::ResponseFormat>,
377    /// Override the context window (tokens) that bounds the running history
378    /// each turn. `None` uses the registry's window for `model` (`0` when
379    /// unknown, which disables compaction). Resolved through
380    /// [`resolve_context_window`], which clamps a value ABOVE a known registry
381    /// window back down to it: compaction exists to stop provider-side
382    /// truncation of the original task (see `compact_history_to_window`), and
383    /// a window larger than the real one would recreate exactly that. A value
384    /// below the registry window is honored as-is — it only tightens.
385    pub context_window_override: Option<usize>,
386    /// Refuse any tool call whose name is not among `tools` with an error
387    /// result, before approval or dispatch. Set on a `delegate` child so the
388    /// tool subset it was granted holds at EXECUTION, not just advertisement:
389    /// every tool is registered with the runtime, so a hallucinated call to an
390    /// ungranted one would otherwise run (the same reason `run_task`'s GUI
391    /// sub-agent enforces its restriction on the call, not the def).
392    ///
393    /// `false` everywhere else, so existing callers whose advertised list is
394    /// deliberately narrower than the registry keep today's behavior.
395    pub refuse_unadvertised_tools: bool,
396    /// Validates a parsed final answer against the caller's JSON Schema.
397    /// Carried as a closure so `car-server-core` needs no schema-validation
398    /// dependency: `car-cli` compiles the `--json-schema` file with the
399    /// `jsonschema` crate it already has and passes `Validator::is_valid`
400    /// here. Without it a `JsonSchema` format is parse-only in the loop — and
401    /// since tool turns never carry the format on the wire, NOBODY would
402    /// enforce the schema on a tool-bearing run. Ignored for `JsonObject`.
403    pub response_format_validator: Option<ResponseFormatValidator>,
404    /// Run-level ceiling on `delegate` use. `None` applies
405    /// [`DelegateBudget::default`] (20 delegations, 300 child turns); a call
406    /// past either limit is an error result, never a spawn. Per parent run —
407    /// children cannot delegate, so nothing nests under it.
408    pub delegate_budget: Option<DelegateBudget>,
409}
410
411/// A compiled JSON-Schema check for the final answer — `true` when the parsed
412/// answer conforms. See [`AssistantConfig::response_format_validator`].
413pub type ResponseFormatValidator = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
414
415/// How much delegating one run may do, whatever each child's own cap says.
416/// Bounds the model-call amplification a delegating parent can cause: without
417/// it a parent at `max_turns` 50 could issue 50 children of 60 turns each.
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub struct DelegateBudget {
420    /// Delegations that may be spawned in one run.
421    pub max_delegations: u32,
422    /// Child turns, summed over every delegation in the run.
423    pub max_child_turns: u32,
424}
425
426impl Default for DelegateBudget {
427    fn default() -> Self {
428        Self {
429            max_delegations: 20,
430            max_child_turns: 300,
431        }
432    }
433}
434
435/// The effective context window for a run: the caller's override, clamped to
436/// the registry window when that is known and smaller. Returns the window plus
437/// an advisory when the clamp fired, so the caller can surface it.
438///
439/// `registry_window == 0` means "unknown" (local/test generators): an override
440/// is then taken at face value, because there is nothing to clamp against and
441/// the alternative — ignoring it — would silently disable the compaction the
442/// caller asked for.
443pub fn resolve_context_window(
444    override_tokens: Option<usize>,
445    registry_window: usize,
446) -> (usize, Option<String>) {
447    match override_tokens {
448        None => (registry_window, None),
449        Some(requested) if registry_window > 0 && requested > registry_window => (
450            registry_window,
451            Some(format!(
452                "context window override {requested} exceeds the model's known window \
453                 {registry_window}; using {registry_window} (a larger value would let the \
454                 history overflow the real window and truncate the task provider-side, \
455                 which is what compaction exists to prevent)"
456            )),
457        ),
458        Some(requested) => (requested, None),
459    }
460}
461
462/// Does a final answer satisfy the requested [`car_inference::ResponseFormat`]?
463///
464/// `JsonObject` requires a JSON *object*; `JsonSchema` requires parseable JSON
465/// that also satisfies `validator` when the caller supplied one (the CLI
466/// compiles the schema file and passes its check in) — parse-only without one,
467/// deliberately not a partial schema re-implementation in this crate.
468/// Tolerates a ```json fence around the payload, which models emit even under
469/// JSON mode; the caller strips it via [`extract_json_payload`].
470pub fn final_text_matches_format(
471    text: &str,
472    format: &car_inference::ResponseFormat,
473    validator: Option<&ResponseFormatValidator>,
474) -> bool {
475    let payload = extract_json_payload(text);
476    match format {
477        car_inference::ResponseFormat::JsonObject => {
478            serde_json::from_str::<Value>(payload).is_ok_and(|v| v.is_object())
479        }
480        car_inference::ResponseFormat::JsonSchema { .. } => {
481            match serde_json::from_str::<Value>(payload) {
482                // The schema itself, when the caller compiled one. Parse-only
483                // otherwise (this crate carries no validator of its own).
484                Ok(v) => validator.is_none_or(|is_valid| is_valid(&v)),
485                Err(_) => false,
486            }
487        }
488    }
489}
490
491/// Strip a single surrounding ``` / ```json fence, if present. Returns the
492/// trimmed input otherwise.
493pub fn extract_json_payload(text: &str) -> &str {
494    let t = text.trim();
495    let Some(rest) = t.strip_prefix("```") else {
496        return t;
497    };
498    let Some(rest) = rest.strip_suffix("```") else {
499        return t;
500    };
501    // Drop an optional language tag on the opening fence line.
502    let rest = match rest.split_once('\n') {
503        Some((tag, body)) if tag.trim().chars().all(|c| c.is_ascii_alphanumeric()) => body,
504        _ => rest,
505    };
506    rest.trim()
507}
508
509/// The one-shot nudge sent when the final answer did not match the requested
510/// format. Delivered as a user-role message: `Message::System` appears once at
511/// the head of a conversation, and a mid-transcript system message is not a
512/// shape every provider accepts.
513const FORMAT_REPAIR_NUDGE: &str =
514    "Your previous answer was not the requested JSON. Return only the JSON object — \
515     no prose, no code fence, no tool calls.";
516/// Schema variant of the nudge — a schema's root need not be an object, so it
517/// must not say "object".
518const FORMAT_REPAIR_NUDGE_SCHEMA: &str =
519    "Your previous answer did not match the required JSON Schema. Return only JSON that \
520     conforms to the schema — no prose, no code fence, no tool calls.";
521
522/// The nudge for one format.
523fn format_repair_nudge(format: &car_inference::ResponseFormat) -> &'static str {
524    match format {
525        car_inference::ResponseFormat::JsonObject => FORMAT_REPAIR_NUDGE,
526        car_inference::ResponseFormat::JsonSchema { .. } => FORMAT_REPAIR_NUDGE_SCHEMA,
527    }
528}
529
530/// Emitted (as [`AssistantEvent::Text`]) when the repair fires, so the run's
531/// event stream records that the final answer was re-asked rather than taken
532/// as returned.
533pub const FORMAT_REPAIR_NOTICE: &str =
534    "[format repair: final answer was not the requested JSON; re-asked the model once with no tools]";
535/// Emitted when the single repair also missed. The repaired text is still the
536/// answer — the contract is "one repair", not "retry until valid".
537pub const FORMAT_REPAIR_STILL_INVALID: &str =
538    "[format repair: the repaired answer still does not match the requested format; returning it as-is]";
539/// Prefix of the notice emitted when the repair call itself failed (for
540/// example an Anthropic-protocol model rejecting `response_format`). The DRAFT
541/// answer is returned — a provider that cannot enforce the format is not a
542/// reason to throw away the answer the model already gave.
543pub const FORMAT_REPAIR_FAILED_PREFIX: &str = "[format repair failed:";
544
545/// The loop-intercepted sub-agent tool. Advertised like any other tool (so a
546/// caller can allowlist or omit it) but never dispatched to the executor: the
547/// loop recognizes the name and runs a child loop in-process — the same shape
548/// as `run_task`'s GUI sub-agent, chosen over a `ToolExecutor` because an
549/// executor is built before the `Runtime` and `AssistantConfig` a child needs.
550pub const DELEGATE_TOOL: &str = "delegate";
551/// Child turn budget when the call names none.
552pub const DELEGATE_DEFAULT_MAX_TURNS: u32 = 25;
553/// Hard ceiling on a child's turn budget, whatever the call asks for.
554pub const DELEGATE_MAX_TURNS_CAP: u32 = 60;
555
556/// The names advertised in `tools`, minus [`DELEGATE_TOOL`] — what a child may
557/// be granted. The delegate itself is excluded on purpose: no nesting in v1.
558fn delegable_tool_names(tools: &[Value]) -> Vec<String> {
559    tools
560        .iter()
561        .filter_map(|d| d.get("name").and_then(Value::as_str))
562        .filter(|n| *n != DELEGATE_TOOL)
563        .map(str::to_string)
564        .collect()
565}
566
567/// Build the `delegate` tool def over the parent's advertised tools. The
568/// `tools` parameter is a JSON-Schema `enum` of the parent's own tool names —
569/// the verifiable subset precondition `car-multi`'s `spawn_subtask` uses — so
570/// the validator rejects an escalation before the loop's own check does.
571///
572/// `tier: read_only`: the delegation itself changes nothing; the CHILD's calls
573/// are what the gates see, and it inherits every gate the parent has.
574/// `mutating: true` so a delegation that FINISHES counts as progress for the
575/// no-progress guard (it is a real action, not a re-read); one that stalls,
576/// errors, or hits its cap returns `ok: false` and does not.
577pub fn delegate_tool_def(parent_tools: &[Value]) -> Value {
578    let names = delegable_tool_names(parent_tools);
579    json!({
580        "name": DELEGATE_TOOL,
581        "tier": "read_only",
582        "mutating": true,
583        "description": "Hand one self-contained sub-task to a fresh sub-agent that shares \
584            your model, permissions, and working directory but starts with an EMPTY \
585            transcript: it sees only the goal you write, not this conversation. Use it \
586            to keep a long exploration or a noisy batch of tool output out of your own \
587            context. It runs to completion before this call returns and you receive \
588            ONLY its final written answer, so put everything it needs in `goal` and \
589            ask it to report exactly what you need back. It cannot delegate further.",
590        "parameters": {
591            "type": "object",
592            "properties": {
593                "goal": {
594                    "type": "string",
595                    "description": "The single, self-contained task, with all the context the sub-agent needs and what to report back."
596                },
597                "tools": {
598                    "type": "array",
599                    "items": { "type": "string", "enum": names },
600                    "description": "Tools to grant the sub-agent. Must be a subset of your own; omit for all of them."
601                },
602                "max_turns": {
603                    "type": "integer",
604                    "minimum": 1,
605                    "maximum": DELEGATE_MAX_TURNS_CAP,
606                    "description": "Turn budget for the sub-agent (default 25). It reports an error if it runs out."
607                }
608            },
609            "required": ["goal"]
610        }
611    })
612}
613
614/// A parsed `delegate` call.
615#[derive(Debug, Clone, PartialEq)]
616pub struct DelegateRequest {
617    pub goal: String,
618    /// `None` = the parent's whole (delegable) set.
619    pub tools: Option<Vec<String>>,
620    pub max_turns: u32,
621}
622
623/// Parse the call's arguments. Shape errors are the model's to fix, so they
624/// come back as an error result rather than sinking the run.
625pub fn parse_delegate_params(params: &Value) -> Result<DelegateRequest, String> {
626    let goal = params
627        .get("goal")
628        .and_then(Value::as_str)
629        .map(str::trim)
630        .filter(|g| !g.is_empty())
631        .ok_or("delegate needs a non-empty `goal` string")?
632        .to_string();
633    let tools = match params.get("tools") {
634        None | Some(Value::Null) => None,
635        Some(Value::Array(items)) => Some(
636            items
637                .iter()
638                .map(|v| {
639                    v.as_str().map(str::to_string).ok_or_else(|| {
640                        "delegate `tools` must be an array of tool names".to_string()
641                    })
642                })
643                .collect::<Result<Vec<_>, _>>()?,
644        ),
645        Some(_) => return Err("delegate `tools` must be an array of tool names".into()),
646    };
647    let max_turns = match params.get("max_turns") {
648        None | Some(Value::Null) => DELEGATE_DEFAULT_MAX_TURNS,
649        Some(v) => {
650            let n = v
651                .as_u64()
652                .filter(|n| *n >= 1)
653                .ok_or("delegate `max_turns` must be a positive integer")?;
654            (n.min(DELEGATE_MAX_TURNS_CAP as u64)) as u32
655        }
656    };
657    Ok(DelegateRequest {
658        goal,
659        tools,
660        max_turns,
661    })
662}
663
664/// Derive the child's config from the parent's. Everything is the parent's
665/// (`clone()`) except:
666/// * `tools` — the requested subset of the parent's delegable tools (default:
667///   all of them), never including `delegate` itself. A name outside the
668///   parent's set is an escalation and is refused here, mirroring
669///   `spawn_subtask`'s defense-in-depth check behind its schema enum.
670/// * `refuse_unadvertised_tools` — on, so the subset holds at execution.
671/// * `max_turns` — the call's (capped) budget.
672/// * `todos` — none; the parent's task list is not the child's.
673/// * `response_format` — none; children answer in prose that the parent reads.
674/// `gated_tools`, `approval_policy`, `model`, `strict_model`,
675/// `context_window_override`, `proactive_memory`, `tool_labels` and
676/// `value_store_previews` are inherited unchanged: a child can do nothing its
677/// parent could not.
678pub fn delegate_child_config(
679    parent: &AssistantConfig,
680    req: &DelegateRequest,
681) -> Result<AssistantConfig, String> {
682    let delegable = delegable_tool_names(&parent.tools);
683    let requested: Vec<String> = match &req.tools {
684        Some(list) => list.clone(),
685        None => delegable.clone(),
686    };
687    let escalations: Vec<&String> = requested
688        .iter()
689        .filter(|t| !delegable.iter().any(|d| d == *t))
690        .collect();
691    if !escalations.is_empty() {
692        let nested = escalations.iter().any(|t| *t == DELEGATE_TOOL);
693        return Err(format!(
694            "privilege escalation rejected: sub-agent tools {escalations:?} are not a subset of \
695             your own tools{}",
696            if nested {
697                " (a sub-agent cannot delegate further)"
698            } else {
699                ""
700            }
701        ));
702    }
703    let tools: Vec<Value> = parent
704        .tools
705        .iter()
706        .filter(|d| {
707            d.get("name")
708                .and_then(Value::as_str)
709                .is_some_and(|n| requested.iter().any(|r| r == n))
710        })
711        .cloned()
712        .collect();
713    Ok(AssistantConfig {
714        tools,
715        refuse_unadvertised_tools: true,
716        response_format_validator: None,
717        delegate_budget: None,
718        max_turns: req.max_turns,
719        todos: None,
720        response_format: None,
721        ..parent.clone()
722    })
723}
724
725/// The child's starting transcript: the parent's leading system prompt(s) and
726/// the goal — nothing else from the parent. A fresh context is the point.
727fn delegate_child_history(parent_messages: &[Message], goal: &str) -> Vec<Message> {
728    let mut history: Vec<Message> = parent_messages
729        .iter()
730        .take_while(|m| matches!(m, Message::System { .. }))
731        .cloned()
732        .collect();
733    history.push(Message::User {
734        content: goal.to_string(),
735    });
736    history
737}
738
739/// What a finished delegation hands back to the parent's transcript.
740struct DelegateOutcome {
741    ok: bool,
742    /// The tool-result content: the child's final text (capped) on success, a
743    /// JSON error carrying the reason otherwise.
744    content: String,
745    turns: u32,
746    /// Whether any of the child's tool results crossed the trust boundary,
747    /// so the parent's `ToolResult` is marked accordingly.
748    external: bool,
749    /// The child's own receipts, for the parent to merge (tagged `via`).
750    receipts: Vec<AssistantToolReceipt>,
751    /// Whether a child loop actually ran (a parse or escalation refusal does
752    /// not count against the run's delegation budget).
753    spawned: bool,
754}
755
756/// Run one `delegate` call to completion. Sequential and in-process: the
757/// parent's turn does not continue until the child returns.
758///
759/// Returns an explicitly boxed `dyn Future + Send` rather than being an
760/// `async fn`: the loop awaits this, and this awaits the loop, so an inferred
761/// future type would leave `Send` as an unsolvable cycle ("cannot satisfy …:
762/// Send"). Naming the type here is what lets callers `tokio::spawn` the loop.
763#[allow(clippy::too_many_arguments)]
764fn run_delegate<'a>(
765    generator: &'a dyn TurnGenerator,
766    runtime: &'a Runtime,
767    parent: &'a AssistantConfig,
768    parent_messages: &'a [Message],
769    params: &'a Value,
770    cancel: &'a std::sync::atomic::AtomicBool,
771    approval: Option<&'a dyn ApprovalGate>,
772    runtime_session_id: Option<&'a str>,
773    redrive_ungrounded_summary: bool,
774    tool_labels: &'a HashMap<String, car_verify::infoflow::ToolLabels>,
775) -> std::pin::Pin<Box<dyn std::future::Future<Output = DelegateOutcome> + Send + 'a>> {
776    Box::pin(async move {
777        let req = match parse_delegate_params(params) {
778            Ok(r) => r,
779            Err(e) => {
780                return DelegateOutcome {
781                    ok: false,
782                    content: cap(json!({ "error": e }).to_string()),
783                    turns: 0,
784                    external: false,
785                    receipts: Vec::new(),
786                    spawned: false,
787                }
788            }
789        };
790        let child_cfg = match delegate_child_config(parent, &req) {
791            Ok(c) => c,
792            Err(e) => {
793                return DelegateOutcome {
794                    ok: false,
795                    content: cap(json!({ "error": e }).to_string()),
796                    turns: 0,
797                    external: false,
798                    receipts: Vec::new(),
799                    spawned: false,
800                }
801            }
802        };
803        let mut child_messages = delegate_child_history(parent_messages, &req.goal);
804        // The child's events stay inside the child: the parent's stream records
805        // the delegation as ONE tool call + result (+ a one-line summary), which is
806        // what a `--json` consumer can attribute. `&mut dyn FnMut` on purpose —
807        // a fresh closure type here would re-instantiate the generic loop for
808        // every nesting depth, and `Box::pin` is what lets an async fn recurse.
809        let mut child_emit: &mut (dyn FnMut(AssistantEvent) + Send) = &mut |_| {};
810        let child = run_assistant_loop_cancellable_in_session_durable(
811            generator,
812            runtime,
813            &child_cfg,
814            &mut child_messages,
815            cancel,
816            approval,
817            None,
818            runtime_session_id,
819            None,
820            None,
821            redrive_ungrounded_summary,
822            &mut child_emit,
823        )
824        .await;
825        let external = child
826            .tool_receipts
827            .iter()
828            .any(|r| tool_output_is_external(&r.tool, tool_labels));
829        if child.status == "success" {
830            DelegateOutcome {
831                ok: true,
832                content: cap(child.summary),
833                turns: child.turns,
834                external,
835                receipts: child.tool_receipts,
836                spawned: true,
837            }
838        } else {
839            // An unfinished delegation must not read as an answer (the GUI
840            // sub-agent's `is_error` rule): the cap, a stall, a cancel or a
841            // transport error all come back as an error result with the reason.
842            DelegateOutcome {
843                ok: false,
844                content: cap(json!({
845                    "error": format!(
846                        "delegate did not finish (status: {}) after {} turns: {}",
847                        child.status, child.turns, child.summary
848                    )
849                })
850                .to_string()),
851                turns: child.turns,
852                external,
853                receipts: child.tool_receipts,
854                spawned: true,
855            }
856        }
857    })
858}
859
860/// Resolves a per-agent approval decision for a tool call. Built by the caller
861/// (chat.rs) from the loaded `AgentPermissionPolicy` + the session's agent id +
862/// a risk classifier, so the loop stays decoupled from the policy store.
863pub type ApprovalPolicyFn =
864    std::sync::Arc<dyn Fn(&str, &Value) -> ToolApprovalDecision + Send + Sync>;
865
866/// What the per-agent policy says to do with a tool call before it runs.
867pub enum ToolApprovalDecision {
868    /// Auto-allow: run without asking.
869    Allow,
870    /// Require human approval (routes through the `ApprovalGate`).
871    RequireApproval,
872    /// Refuse outright with a reason.
873    Deny(String),
874}
875
876/// The outcome of an approval request.
877pub enum ApprovalDecision {
878    Approved,
879    Denied(String),
880}
881
882/// The human-in-the-loop seam. Consulted by the loop before running a
883/// `gated_tools` action. Implementations: a terminal stdin prompt (REPL /
884/// one-shot) or the chat `approval_pending` → park → resolve flow. When no gate
885/// is wired, a gated action is denied with an actionable message.
886#[async_trait::async_trait]
887pub trait ApprovalGate: Send + Sync {
888    async fn request(&self, tool: &str, params: &Value) -> ApprovalDecision;
889
890    async fn request_action(&self, _call_id: &str, tool: &str, params: &Value) -> ApprovalDecision {
891        self.request(tool, params).await
892    }
893
894    /// Durable write-ahead dispatch marker for an approved consequential
895    /// action. A failure is fail-closed: the runtime must not execute.
896    async fn before_dispatch(
897        &self,
898        _call_id: &str,
899        _tool: &str,
900        _params: &Value,
901    ) -> Result<(), String> {
902        Ok(())
903    }
904
905    /// Durable terminal action receipt. If this append fails, the prior
906    /// dispatched record remains and resume classifies it indeterminate.
907    async fn after_dispatch(
908        &self,
909        _call_id: &str,
910        _tool: &str,
911        _params: &Value,
912        _ok: bool,
913        _receipt: &Value,
914    ) -> Result<(), String> {
915        Ok(())
916    }
917}
918
919/// The terminal result of a loop run.
920#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
921pub struct AssistantModelAttribution {
922    /// Canonical immutable id of the model that served the turn.
923    pub model_id: String,
924    /// True only when the appended on-device last resort actually served.
925    pub local_last_resort: bool,
926}
927
928pub struct AssistantOutcome {
929    /// Every value this field can hold:
930    ///
931    /// - `"success"` — the model finished with an answer.
932    /// - `"max_turns"` — the turn budget ran out.
933    /// - `"stalled"` — the no-progress guard broke a loop that was repeating
934    ///   itself.
935    /// - `"error"` — the run failed (inference/transport/durability).
936    /// - `"auth_required"` — the turn was refused because of the Parslee
937    ///   account; see [`AssistantOutcome::auth_required`] for the reason.
938    /// - `"cancelled"` — the caller cancelled mid-turn.
939    /// - `"goal_pending"` — the goal loop's initial value, before any
940    ///   iteration has produced an outcome.
941    pub status: &'static str,
942    /// The final assistant text (or the error message).
943    pub summary: String,
944    /// Turns consumed, including an in-flight turn whose generation failed.
945    pub turns: u32,
946    /// Primary model turns that returned successfully before the run ended.
947    /// Unlike [`Self::turns`], this never counts the failed in-flight attempt.
948    pub turns_completed: u32,
949    /// Names of tools that executed successfully.
950    pub tools_called: Vec<String>,
951    /// Tool executions attempted during this loop run, used to ground final
952    /// prose claims such as "I ran the tests" against actual receipts.
953    ///
954    /// The leading [`Self::prior_receipts`] entries were replayed from the
955    /// persisted thread rather than executed here — see that field.
956    pub tool_receipts: Vec<AssistantToolReceipt>,
957    /// How many leading [`Self::tool_receipts`] entries came from earlier turns
958    /// of the same thread rather than from this invocation.
959    ///
960    /// Grounding wants the whole vec: a continuity turn may legitimately cite a
961    /// previous turn's evidence. A host receipt wants the opposite — it reports
962    /// what the person just watched happen — so every wire projection slices
963    /// with [`Self::run_receipts`]. Without the split, turn 12 of a long
964    /// session published turns 1-4's rows (`tool_receipts_for_wire` keeps the
965    /// FIRST 100) and accumulated desktop actions over the whole session.
966    pub prior_receipts: usize,
967    /// Ordered attribution for every completed model call in this run. This is
968    /// retained separately from the compactable transcript so the terminal run
969    /// receipt remains complete even after older messages age out.
970    pub models_served: Vec<AssistantModelAttribution>,
971    /// The model id that produced the final turn (authoritative attribution;
972    /// empty if no generate completed). Threaded out so the goal loop can stamp
973    /// `model_id`/`model_tier` onto `GoalEvaluated`, mirroring the provenance
974    /// `record_turn_completed` already stamps on the default path.
975    pub model_used: String,
976    /// `Some(reason)` exactly when `status == "auth_required"`. Carried as data
977    /// so the chat service and the `--json` renderer can build their terminal
978    /// frames without re-deriving the reason from the summary prose.
979    pub auth_required: Option<AuthRequiredReason>,
980    /// Typed terminal failure, preserved separately from [`Self::summary`].
981    /// `None` for successful/refused/cancelled outcomes and for older
982    /// non-inference failure sites that have not acquired a typed cause.
983    pub failure_cause: Option<AssistantFailureCause>,
984}
985
986impl AssistantOutcome {
987    /// The receipts this invocation actually produced, with the replayed
988    /// transcript seed removed. This is what a host receipt reports on.
989    pub fn run_receipts(&self) -> &[AssistantToolReceipt] {
990        let start = self.prior_receipts.min(self.tool_receipts.len());
991        &self.tool_receipts[start..]
992    }
993}
994
995#[derive(Clone, Debug)]
996pub struct AssistantToolReceipt {
997    pub tool: String,
998    /// Loop-generated correlation id, unique across turns even when a provider
999    /// repeats its own tool-call ids. A receipt merged up from a `delegate`
1000    /// child is namespaced `<parent call id>/<child call id>`, because the
1001    /// child loop numbers its turns from one and would otherwise collide with
1002    /// the parent's own first call.
1003    pub call_id: Option<String>,
1004    /// One-based call position within the turn. `None` only for legacy/test
1005    /// receipts that were not produced by the live assistant loop.
1006    pub sequence: Option<u32>,
1007    pub ok: bool,
1008    pub params: Value,
1009    /// The uncapped observation retained long enough to build a redacted,
1010    /// bounded host receipt. The model transcript receives a separate capped
1011    /// copy, so truncation cannot turn structured evidence into invalid JSON.
1012    pub result: Option<String>,
1013    /// `Some("delegate:<call id>")` for a receipt a `delegate` child produced
1014    /// and the parent merged into its own list, so grounding and
1015    /// `receipts.by_tool` see the child's real calls while a reader can still
1016    /// tell them from the parent's own. `None` for the parent's own calls.
1017    pub via: Option<String>,
1018}
1019
1020fn transcript_tool_receipts(messages: &[Message]) -> Vec<AssistantToolReceipt> {
1021    let mut calls: std::collections::HashMap<
1022        String,
1023        std::collections::VecDeque<(String, Value, String, u32)>,
1024    > = std::collections::HashMap::new();
1025    let mut receipts = Vec::new();
1026    let mut turn = 0u32;
1027    for message in messages {
1028        match message {
1029            Message::Assistant { tool_calls, .. } => {
1030                turn = turn.saturating_add(1);
1031                for (index, call) in tool_calls.iter().enumerate() {
1032                    if let Some(id) = call.id.as_deref() {
1033                        let sequence = u32::try_from(index + 1).unwrap_or(u32::MAX);
1034                        calls.entry(id.to_string()).or_default().push_back((
1035                            call.name.clone(),
1036                            serde_json::to_value(&call.arguments)
1037                                .unwrap_or_else(|_| Value::Object(Default::default())),
1038                            format!("turn_{turn}_call_{sequence}"),
1039                            sequence,
1040                        ));
1041                    }
1042                }
1043            }
1044            Message::ToolResult {
1045                tool_use_id,
1046                content,
1047                ..
1048            } => {
1049                let Some((tool, params, call_id, sequence)) = calls
1050                    .get_mut(tool_use_id)
1051                    .and_then(std::collections::VecDeque::pop_front)
1052                else {
1053                    continue;
1054                };
1055                let parsed = serde_json::from_str::<Value>(content).ok();
1056                let ok = parsed
1057                    .as_ref()
1058                    .map(|value| {
1059                        value.get("error").is_none()
1060                            && value.get("ok").and_then(Value::as_bool) != Some(false)
1061                            && value.get("status").and_then(Value::as_str) != Some("Failed")
1062                    })
1063                    .unwrap_or_else(|| {
1064                        let lower = content.to_ascii_lowercase();
1065                        !lower.contains("declined by user")
1066                            && !lower.contains("tool call denied")
1067                            && !lower.starts_with("error:")
1068                    });
1069                receipts.push(AssistantToolReceipt {
1070                    tool,
1071                    call_id: Some(call_id),
1072                    sequence: Some(sequence),
1073                    ok,
1074                    params,
1075                    result: Some(content.clone()),
1076                    via: None,
1077                });
1078            }
1079            _ => {}
1080        }
1081    }
1082    receipts
1083}
1084
1085/// The turn number this invocation's first turn follows, so `turn_<n>_call_<k>`
1086/// stays unique for the life of a session.
1087///
1088/// Counting Assistant messages alone is not stable:
1089/// [`compact_history_measured`] DROPS older messages mid-run, so the next
1090/// invocation recomputes a SMALLER count and mints `turn_3_call_1` a second
1091/// time in one session. That is the id a host correlates a streamed
1092/// `tool_result` to its receipt by, and the uniqueness
1093/// `docs/websocket-protocol.md` documents.
1094///
1095/// Adding the compaction notice's running dropped-message total restores
1096/// monotonicity. A compaction removes `D` messages of which `A <= D` were
1097/// Assistant, so this offset moves by `D - A >= 0` — never down. Since every
1098/// turn that mints an id also appends exactly one Assistant message, the next
1099/// invocation starts strictly above the last id this one minted.
1100fn transcript_turn_offset(messages: &[Message]) -> u32 {
1101    let assistant_turns = messages
1102        .iter()
1103        .filter(|message| matches!(message, Message::Assistant { .. }))
1104        .count();
1105    // Notices accumulate into one pinned marker, but sum defensively: an extra
1106    // marker may only push the offset up, which cannot cause a collision.
1107    let compacted_away: usize = messages
1108        .iter()
1109        .filter_map(parse_compaction_notice)
1110        .map(|(dropped, _tokens)| dropped)
1111        .sum();
1112    eprintln!(
1113        "DEBUG2 assistant={assistant_turns} compacted={compacted_away} len={}",
1114        messages.len()
1115    );
1116    u32::try_from(assistant_turns.saturating_add(compacted_away)).unwrap_or(u32::MAX)
1117}
1118
1119/// Bound an observation to [`OBSERVATION_CAP`], stating what was lost (#813).
1120///
1121/// This is still destructive truncation — the elided bytes are NOT retained,
1122/// and recovering them means re-running the tool with a narrower query. The
1123/// full fix is a session value store plus typed previews with handles, which
1124/// #813 rightly says needs its own design pass and a `car-bench` A/B before it
1125/// changes the model-facing transcript shape.
1126///
1127/// What is fixable without that: the marker used to be a bare `…[truncated]…`,
1128/// so a model could not tell whether it had lost 10 bytes or 10 MB, and had no
1129/// signal that re-running was the only recovery. A model reasoning over a
1130/// clipped table would silently treat it as complete. Reporting the true size
1131/// and the elided amount costs nothing and makes the loss legible.
1132fn cap(mut s: String) -> String {
1133    let total = s.len();
1134    if total <= OBSERVATION_CAP {
1135        return s;
1136    }
1137    let mut end = OBSERVATION_CAP;
1138    while !s.is_char_boundary(end) {
1139        end -= 1;
1140    }
1141    let elided = total - end;
1142    s.truncate(end);
1143    // Leading newline so the notice can't be mistaken for part of the payload
1144    // (a clipped CSV row, a half-written JSON object).
1145    s.push_str(&format!(
1146        "\n…[truncated: showing first {end} of {total} bytes; {elided} bytes elided \
1147         and NOT retained. To see the rest, re-run this tool with a narrower \
1148         query — the elided bytes cannot be recovered by asking for them.]…"
1149    ));
1150    s
1151}
1152
1153/// Fences for the per-turn runtime state block (#814 items 2-3). Explicit
1154/// delimiters because the block is appended to a message that is usually a tool
1155/// result, and unfenced runtime text there would read as part of the tool's
1156/// output.
1157const STATE_BLOCK_OPEN: &str = "\n\n<runtime-state>\n";
1158const STATE_BLOCK_CLOSE: &str = "\n</runtime-state>";
1159
1160/// Append the per-turn state block to the LAST message's content.
1161///
1162/// Appended to an existing message rather than added as a new one, which is the
1163/// only placement that actually satisfies #814 item 3 on every provider. Item 3
1164/// asks for the tail so the cached prefix stays byte-stable — but the Anthropic
1165/// and Gemini handlers FOLD every `Message::System` into the top-level system
1166/// field (`protocol.rs`), so a trailing System block would land in the prefix
1167/// and be rewritten every turn, causing precisely the cache invalidation the
1168/// item exists to prevent. A trailing `Message::User` would keep its position,
1169/// but after a tool result it produces consecutive user-role turns, which is a
1170/// provider-shape risk not worth taking for a status line.
1171///
1172/// Operates on the request copy, never the durable history: the block is
1173/// regenerated every turn, so persisting it would stack stale copies.
1174fn append_state_block(messages: &mut [Message], block: &str) {
1175    let Some(last) = messages.last_mut() else {
1176        return;
1177    };
1178    let fenced = format!("{STATE_BLOCK_OPEN}{block}{STATE_BLOCK_CLOSE}");
1179    match last {
1180        Message::System { content }
1181        | Message::User { content }
1182        | Message::Assistant { content, .. }
1183        | Message::ToolResult { content, .. } => content.push_str(&fenced),
1184        // No text slot to append to; skipping is better than restructuring the
1185        // turn, and the next turn's message will carry the block.
1186        _ => {}
1187    }
1188}
1189
1190/// How many remembered subjects reach the state block. Bounded deliberately:
1191/// this is a pointer to durable state, not a copy of it.
1192const STATE_BLOCK_MAX_FACTS: usize = 5;
1193
1194/// Subjects of the facts this run wrote via `remember`, oldest first.
1195///
1196/// Derived from the run's tool receipts rather than from a second tracker: the
1197/// loop already records every call with its params, so there is nothing to keep
1198/// in sync and no way for the two views to disagree. Only *successful* calls
1199/// count — a rejected `remember` wrote nothing, and listing it would tell the
1200/// model it knows something it does not.
1201fn recent_fact_subjects(receipts: &[AssistantToolReceipt]) -> Vec<String> {
1202    let mut subjects: Vec<String> = Vec::new();
1203    for receipt in receipts.iter().filter(|r| r.ok && r.tool == "remember") {
1204        let Some(subject) = receipt.params.get("subject").and_then(Value::as_str) else {
1205            continue;
1206        };
1207        let subject = subject.trim();
1208        if subject.is_empty() {
1209            continue;
1210        }
1211        // A re-remember supersedes the earlier write rather than adding a second
1212        // fact (`memory.rs`), so the subject moves to the most-recent position
1213        // instead of appearing twice and inflating the count.
1214        subjects.retain(|s| s != subject);
1215        subjects.push(subject.to_string());
1216    }
1217    subjects
1218}
1219
1220/// Compose the per-turn state block from live run state, or `None` when there
1221/// is nothing worth spending tokens on.
1222///
1223/// Subjects only, never bodies. The block exists so the model knows a fact
1224/// *exists* without having to remember writing it — turning a speculative
1225/// `recall` into an informed one. Inlining the bodies would duplicate memgine's
1226/// job without its relevance ranking, and would let the block grow into the
1227/// largest thing in the context, which is the failure mode the whole per-turn
1228/// design is bounded against.
1229fn render_state_block(todo: Option<String>, facts: &[String]) -> Option<String> {
1230    let mut sections: Vec<String> = Vec::new();
1231    if let Some(todo) = todo {
1232        sections.push(todo);
1233    }
1234    if !facts.is_empty() {
1235        // Keep the most RECENT subjects when over the cap — the oldest are the
1236        // ones the model is least likely to still be acting on.
1237        let hidden = facts.len().saturating_sub(STATE_BLOCK_MAX_FACTS);
1238        let listed = facts
1239            .iter()
1240            .skip(hidden)
1241            .map(String::as_str)
1242            .collect::<Vec<_>>()
1243            .join(", ");
1244        let mut line = format!("remembered this run: {listed}");
1245        if hidden > 0 {
1246            line.push_str(&format!(" (+{hidden} earlier)"));
1247        }
1248        line.push_str("\n  subjects only — call `recall` for the content");
1249        sections.push(line);
1250    }
1251    (!sections.is_empty()).then(|| sections.join("\n"))
1252}
1253
1254/// Keep at least this many of the most-recent messages when compacting, so a
1255/// window-bounded run never loses the immediate working context.
1256const HISTORY_MIN_TAIL: usize = 6;
1257
1258/// The share of a model's context window a running history may occupy before
1259/// compaction fires, as an exact integer fraction: 3/4 (75%).
1260///
1261/// The remaining quarter is the headroom for the model's own output and the
1262/// next turn's tool results — there is no separate reserve. This was spelled
1263/// `context_window / 4 * 3` inside [`compact_history_measured`], which read as
1264/// an implementation detail of that function rather than what it is: the
1265/// harness-wide policy for every multi-turn driver (the assistant loop, the
1266/// coder native loop, the declarative-agent runner). Naming it makes moving
1267/// the number a one-line decision instead of a grep, and makes each driver's
1268/// budget nameable in its own logs.
1269///
1270/// Evaluate as `window / DENOMINATOR * NUMERATOR` — integer division first, the
1271/// order the original expression used, so no window changes its budget by a
1272/// token.
1273pub(crate) const HISTORY_BUDGET_NUMERATOR: usize = 3;
1274/// Denominator of [`HISTORY_BUDGET_NUMERATOR`]'s fraction.
1275pub(crate) const HISTORY_BUDGET_DENOMINATOR: usize = 4;
1276
1277/// Tokens of `context_window` a history may occupy before compaction drops its
1278/// oldest middle turns. `0` in (unknown window) is `0` out — callers read that
1279/// as "no bound is known", not "no tokens allowed".
1280pub(crate) fn history_budget(context_window: usize) -> usize {
1281    context_window / HISTORY_BUDGET_DENOMINATOR * HISTORY_BUDGET_NUMERATOR
1282}
1283
1284/// Opening of the system message left in place of compacted turns (#815).
1285///
1286/// Doubles as the marker's own identity: [`parse_compaction_notice`] recognizes
1287/// it so a later compaction updates the running totals in place instead of
1288/// stacking notices or dropping the earlier one.
1289const COMPACTION_NOTICE_PREFIX: &str = "[history compacted:";
1290
1291/// Where a compacted run's dropped turns can still be read — which differs by
1292/// caller, so the notice must too.
1293///
1294/// The assistant and coder loops run against a live event log and advertise
1295/// `events_query`, so their notice can tell the model where to look. The
1296/// declarative-agent runner has neither: its tools come from the spec's
1297/// allowlist over a `WorktreeExecutor`, and no event log is bound to the run.
1298/// Pointing that model at `events_query` would be a false recovery path — a
1299/// tool it cannot call, naming a log that does not exist — which is worse than
1300/// saying plainly that the turns are gone.
1301#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1302pub(crate) enum CompactionRecovery {
1303    /// The run has an event log and the `events_query` tool. Default: this is
1304    /// what every caller before the declarative runner had.
1305    #[default]
1306    EventsQuery,
1307    /// Nothing to point at — the dropped turns are gone for this run.
1308    Unrecoverable,
1309}
1310
1311/// Render the marker. The `EventsQuery` arm names `events_query` explicitly
1312/// because a notice that something is missing, without saying how to look, only
1313/// converts a silent failure into a visible dead end; the `Unrecoverable` arm
1314/// says exactly that, for a caller where there is nothing to look in.
1315///
1316/// Both arms keep the `{COMPACTION_NOTICE_PREFIX} <turns> … ~<tokens> …` shape
1317/// so [`parse_compaction_notice`] reads either one back and totals accumulate
1318/// across compactions regardless of which caller wrote them.
1319fn format_compaction_notice(turns: usize, tokens: usize, recovery: CompactionRecovery) -> String {
1320    match recovery {
1321        CompactionRecovery::EventsQuery => format!(
1322            "{COMPACTION_NOTICE_PREFIX} {turns} earlier turns removed to fit the context \
1323             window, ~{tokens} tokens. They are gone from this transcript but the run's \
1324             event log still has them — call `events_query` (e.g. {{\"kinds\": \
1325             [\"action_failed\"], \"limit\": 5}}) to see what was already tried, rather \
1326             than assuming you never tried it.]"
1327        ),
1328        CompactionRecovery::Unrecoverable => format!(
1329            "{COMPACTION_NOTICE_PREFIX} {turns} earlier turns dropped to fit the model's \
1330             context window, ~{tokens} tokens. They are not recoverable in this run — \
1331             work from what is still in this transcript, and do not assume something was \
1332             never tried just because you cannot see it.]"
1333        ),
1334    }
1335}
1336
1337/// Recover the running totals from an existing marker, or `None` if `message`
1338/// is not one.
1339///
1340/// Parses the numbers back out of the text rather than threading a counter
1341/// through every caller: the marker lives in `messages`, which is the only
1342/// state both callers of `compact_history_to_window` (the assistant loop and
1343/// the coder loop) already share.
1344fn parse_compaction_notice(message: &Message) -> Option<(usize, usize)> {
1345    let Message::System { content } = message else {
1346        return None;
1347    };
1348    let rest = content.strip_prefix(COMPACTION_NOTICE_PREFIX)?;
1349    let turns: usize = rest.split_whitespace().next()?.parse().ok()?;
1350    let tokens: usize = rest
1351        .split('~')
1352        .nth(1)?
1353        .split_whitespace()
1354        .next()?
1355        .parse()
1356        .ok()?;
1357    Some((turns, tokens))
1358}
1359
1360/// A message's estimated token cost, using the *same* metric the inference
1361/// layer's window-fit check uses (`media_tokens::messages_history_tokens`: a
1362/// message's serialized-JSON length / 4, with calibrated media accounting).
1363/// Sharing one estimator is load-bearing — if compaction under-counts relative
1364/// to the truncation check, it leaves a history the check still flags.
1365fn approx_message_tokens(m: &Message) -> usize {
1366    car_inference::media_tokens::messages_history_tokens(std::slice::from_ref(m))
1367}
1368
1369/// Bound the running conversation to the model's context window so a long,
1370/// tool-heavy run never overfills it. An overflowed history pushes the model to
1371/// its context limit and can truncate the *original task* provider-side — the
1372/// exact failure the gpt-5.5 benchmark run hit (17× `available_tokens=0`).
1373///
1374/// Deterministic sliding window: keep the system prompt(s) + the original task
1375/// (first user turn) + the most-recent exchanges, dropping the oldest middle
1376/// messages until the estimate fits [`history_budget`] (headroom for the
1377/// model's output + the next tool results). Drops land on a turn boundary so a
1378/// `ToolResult` is never orphaned from the `Assistant` call that produced it —
1379/// a dangling tool result is provider-invalid. No-op when the window is unknown
1380/// (`0`, e.g. local/test generators) or the history already fits.
1381pub(crate) fn compact_history_to_window(messages: &mut Vec<Message>, context_window: usize) {
1382    compact_history_measured(messages, context_window, PromptMeasure::default())
1383}
1384
1385/// What a caller knows about the size of its next request beyond the
1386/// per-message chars/4 estimate. `Default` is "nothing": the estimate alone.
1387///
1388/// The estimate under-counts a real request in two ways this closes. It sees
1389/// only `messages` — not the tool definitions advertised on every turn
1390/// (`fixed_overhead`) — and it counts bytes/4 where code tokenizes denser, so
1391/// a 150k-window run measured ~100k while the provider billed 144k and
1392/// compaction never fired. The provider's own `prompt_tokens` from the
1393/// previous call is ground truth for the messages that call carried
1394/// (`reported`); what was appended since is estimated, scaled by the
1395/// observed reported/estimated ratio when the estimate was off by more than
1396/// 25%, so one compaction lands under budget instead of chasing it.
1397#[derive(Debug, Clone, Copy, Default)]
1398pub(crate) struct PromptMeasure {
1399    /// Per-request tokens the per-message estimate cannot see: the tool
1400    /// definitions (and anything else fixed per call).
1401    pub fixed_overhead: usize,
1402    /// `(prompt_tokens, messages_covered)` from the most recent completed
1403    /// call: the provider-reported input size (all cache buckets summed) and
1404    /// how many leading messages of the current history that request
1405    /// carried. Everything past `messages_covered` was appended since.
1406    pub reported: Option<(usize, usize)>,
1407}
1408
1409/// Per-message token estimates for `messages`, in the order given — the input
1410/// both [`measure_scale`] and the drop loop walk.
1411///
1412/// One function so two callers cannot accidentally estimate differently, and so
1413/// it is visible that estimating is a PASS over the messages: the path-backed
1414/// media estimator reads filesystem metadata, so re-deriving these is neither
1415/// free nor order-neutral.
1416pub(crate) fn message_estimates(messages: &[Message]) -> Vec<usize> {
1417    messages.iter().map(approx_message_tokens).collect()
1418}
1419
1420/// The ratio that maps this run's per-message estimates onto the provider's own
1421/// accounting: `reported / estimate-of-the-same-messages`.
1422///
1423/// **The gate is upward-only.** A scale is learned ONLY when the provider's
1424/// reported usage exceeds 125% of the estimate for the same messages; anything
1425/// at or below that — a report that merely agrees, and a report SMALLER than
1426/// the estimate — leaves the scale at `1.0`. The estimator is known to
1427/// under-count (bytes/4 against a denser tokenizer), so the correction it
1428/// exists to make is upward; scaling a history DOWN toward a low report would
1429/// compact less than the window needs, which is the failure this path prevents.
1430///
1431/// Takes the estimates rather than the messages so the compaction pass, which
1432/// already has them, does not pay for a second estimation pass over the covered
1433/// prefix — that pass is filesystem metadata reads for path-backed media.
1434/// `covered` is clamped to the slice.
1435pub(crate) fn measure_scale(estimates: &[usize], measure: PromptMeasure) -> f64 {
1436    let Some((reported, covered)) = measure.reported else {
1437        return 1.0;
1438    };
1439    let covered = covered.min(estimates.len());
1440    let covered_est: usize = estimates[..covered].iter().sum::<usize>() + measure.fixed_overhead;
1441    if covered_est > 0 && reported * 4 > covered_est * 5 {
1442        reported as f64 / covered_est as f64
1443    } else {
1444        1.0
1445    }
1446}
1447
1448/// What `messages` cost in the provider's accounting: the shared request-level
1449/// estimate plus the caller's fixed overhead, scaled by [`measure_scale`].
1450///
1451/// One number for "how big is this history", so two passes over the same
1452/// history cannot disagree about whether it fits. Identical to the total
1453/// [`compact_history_measured`] decides on wherever a scale was learned, since
1454/// `scale * covered_estimate` IS the reported count by construction.
1455pub(crate) fn scaled_prompt_tokens(
1456    messages: &[Message],
1457    fixed_overhead: usize,
1458    scale: f64,
1459) -> usize {
1460    let estimate =
1461        car_inference::media_tokens::request_prompt_tokens("", None, None, None, Some(messages))
1462            + fixed_overhead;
1463    (estimate as f64 * scale).round() as usize
1464}
1465
1466/// Same as [`compact_history_to_window`] with a [`PromptMeasure`]. WHETHER to
1467/// compact is decided on the best available measure — the provider-reported
1468/// count when there is one — while HOW MUCH to drop still walks the
1469/// per-message estimates (the only per-message measure), scaled to the
1470/// reported total when the two disagree by more than 25%.
1471pub(crate) fn compact_history_measured(
1472    messages: &mut Vec<Message>,
1473    context_window: usize,
1474    measure: PromptMeasure,
1475) {
1476    compact_history_measured_with_recovery(
1477        messages,
1478        context_window,
1479        measure,
1480        CompactionRecovery::default(),
1481    )
1482}
1483
1484/// Same as [`compact_history_measured`] with an explicit
1485/// [`CompactionRecovery`], for a caller whose run cannot honor the default
1486/// notice's `events_query` advice. Only the notice text differs — WHAT is
1487/// dropped, and the tail rule that protects it, are identical.
1488pub(crate) fn compact_history_measured_with_recovery(
1489    messages: &mut Vec<Message>,
1490    context_window: usize,
1491    measure: PromptMeasure,
1492    recovery: CompactionRecovery,
1493) {
1494    if context_window == 0 {
1495        return;
1496    }
1497    let budget = history_budget(context_window);
1498    let estimates: Vec<usize> = message_estimates(messages);
1499    // The fallback total is the shared request-level estimate over the
1500    // history (`media_tokens::request_prompt_tokens`, the same function the
1501    // remote guard's warning uses) plus the per-run overhead the caller
1502    // measured once (tool definitions). The per-message vector above is the
1503    // same estimator applied one message at a time, for the drop loop.
1504    let estimated: usize =
1505        car_inference::media_tokens::request_prompt_tokens("", None, None, None, Some(messages))
1506            + measure.fixed_overhead;
1507    // `scale` maps a per-message estimate onto the provider's accounting.
1508    let (total, scale, reported) = match measure.reported {
1509        Some((reported, covered)) => {
1510            let covered = covered.min(messages.len());
1511            let appended: usize = estimates[covered..].iter().sum();
1512            let scale = measure_scale(&estimates, measure);
1513            let appended_scaled = (appended as f64 * scale).round() as usize;
1514            (reported + appended_scaled, scale, Some(reported))
1515        }
1516        None => (estimated, 1.0, None),
1517    };
1518    let scaled = |tokens: usize| (tokens as f64 * scale).round() as usize;
1519    if total <= budget {
1520        return;
1521    }
1522    tracing::info!(
1523        reported_prompt_tokens = reported,
1524        estimated_prompt_tokens = estimated,
1525        measured_prompt_tokens = total,
1526        scale,
1527        budget,
1528        context_window,
1529        "history exceeds the compaction budget"
1530    );
1531
1532    // Pinned head: leading system prompt(s) + the first user turn (the task).
1533    let mut head_end = 0;
1534    while head_end < messages.len() && matches!(messages[head_end], Message::System { .. }) {
1535        head_end += 1;
1536    }
1537    if head_end < messages.len()
1538        && matches!(
1539            messages[head_end],
1540            Message::User { .. } | Message::UserMultimodal { .. }
1541        )
1542    {
1543        head_end += 1;
1544    }
1545    // A notice from an earlier compaction is part of the pinned head (#815).
1546    // Otherwise it sits first in the drop range and the next compaction erases
1547    // the record that the previous one happened — restoring exactly the silent
1548    // deletion the marker exists to prevent.
1549    let existing_notice = messages
1550        .get(head_end)
1551        .and_then(parse_compaction_notice)
1552        .map(|totals| {
1553            let at = head_end;
1554            head_end += 1;
1555            (at, totals)
1556        });
1557
1558    // Never drop into the most-recent tail.
1559    if messages.len().saturating_sub(head_end) <= HISTORY_MIN_TAIL {
1560        return;
1561    }
1562    let max_drop = messages.len() - HISTORY_MIN_TAIL;
1563
1564    // Drop oldest middle messages until we fit (or run into the tail).
1565    let mut drop_end = head_end;
1566    let mut running = total;
1567    while running > budget && drop_end < max_drop {
1568        running = running.saturating_sub(scaled(estimates[drop_end]));
1569        drop_end += 1;
1570    }
1571    // Land the kept suffix on a valid turn boundary. A Responses continuity
1572    // item precedes its Assistant message, so it is a valid boundary only as
1573    // that pair. If the item itself was just dropped, drop its now-orphaned
1574    // Assistant too; then skip any dangling tool results.
1575    if drop_end > head_end
1576        && drop_end < messages.len()
1577        && matches!(messages[drop_end - 1], Message::ProviderOutputItems { .. })
1578        && matches!(messages[drop_end], Message::Assistant { .. })
1579    {
1580        drop_end += 1;
1581    }
1582    while drop_end < messages.len() && matches!(messages[drop_end], Message::ToolResult { .. }) {
1583        drop_end += 1;
1584    }
1585    if drop_end <= head_end {
1586        return;
1587    }
1588    let dropped = drop_end - head_end;
1589    // Accounted in the same (scaled) measure the decision used, so the
1590    // notice's `~N tokens` is the provider-side size of what was removed.
1591    let dropped_tokens: usize = estimates[head_end..drop_end]
1592        .iter()
1593        .map(|t| scaled(*t))
1594        .sum();
1595    messages.drain(head_end..drop_end);
1596    // Leave a marker where the turns were (#815).
1597    //
1598    // Without one, turns simply cease to exist between one request and the
1599    // next and the transcript reads as continuous from the model's side — so a
1600    // run that degrades after compaction is indistinguishable, in the trace,
1601    // from a model that just got worse. "The model forgot" and "the harness
1602    // deleted it" are different bugs with different fixes, and only one of them
1603    // is the model's.
1604    //
1605    // Pinned into the head below so the next compaction cannot silently drop
1606    // the notice that the previous one happened, and totals accumulate across
1607    // compactions rather than only reporting the latest.
1608    match existing_notice {
1609        Some((at, (prior_turns, prior_tokens))) => {
1610            messages[at] = Message::System {
1611                content: format_compaction_notice(
1612                    prior_turns + dropped,
1613                    prior_tokens + dropped_tokens,
1614                    recovery,
1615                ),
1616            };
1617        }
1618        None => messages.insert(
1619            head_end,
1620            Message::System {
1621                content: format_compaction_notice(dropped, dropped_tokens, recovery),
1622            },
1623        ),
1624    }
1625    tracing::debug!(
1626        dropped_messages = dropped,
1627        kept = messages.len(),
1628        context_window,
1629        budget,
1630        "compacted assistant history to fit the model context window"
1631    );
1632}
1633
1634fn message_memory_text(message: &Message) -> Option<String> {
1635    match message {
1636        Message::System { content }
1637        | Message::User { content }
1638        | Message::Assistant { content, .. }
1639        | Message::ToolResult { content, .. } => {
1640            let trimmed = content.trim();
1641            (!trimmed.is_empty()).then(|| trimmed.to_string())
1642        }
1643        Message::UserMultimodal { content } => {
1644            let text = content
1645                .iter()
1646                .filter_map(|block| match block {
1647                    ContentBlock::Text { text } => Some(text.trim()),
1648                    _ => None,
1649                })
1650                .filter(|s| !s.is_empty())
1651                .collect::<Vec<_>>()
1652                .join("\n");
1653            (!text.is_empty()).then_some(text)
1654        }
1655        _ => None,
1656    }
1657}
1658
1659fn proactive_query_from_messages(messages: &[Message]) -> String {
1660    messages
1661        .iter()
1662        .rev()
1663        .find_map(|m| match m {
1664            Message::User { content } => {
1665                let trimmed = content.trim();
1666                (!trimmed.is_empty()).then(|| trimmed.to_string())
1667            }
1668            Message::UserMultimodal { .. } => message_memory_text(m),
1669            _ => None,
1670        })
1671        .unwrap_or_default()
1672}
1673
1674fn append_context_block(req: &mut GenerateRequest, title: &str, body: &str) {
1675    let block = format!("## {title}\n{body}");
1676    req.context = Some(match req.context.take() {
1677        Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
1678        _ => block,
1679    });
1680}
1681
1682fn proactive_maintenance_event_data(
1683    report: &car_memgine::ProactiveMaintenanceReport,
1684) -> std::collections::HashMap<String, Value> {
1685    let mut data = proactive_trigger_event_data(&report.trigger);
1686    data.insert(
1687        "saved_count".to_string(),
1688        Value::from(report.saved.len() as u64),
1689    );
1690    data.insert(
1691        "skipped_existing".to_string(),
1692        Value::from(report.skipped_existing as u64),
1693    );
1694    data.insert(
1695        "status_updated".to_string(),
1696        Value::from(report.status.is_some()),
1697    );
1698    data
1699}
1700
1701fn proactive_intervention_event_data(
1702    decision: &car_memgine::ProactiveMemoryDecision,
1703) -> std::collections::HashMap<String, Value> {
1704    let mut data = std::collections::HashMap::new();
1705    match decision {
1706        car_memgine::ProactiveMemoryDecision::Inject {
1707            selected,
1708            candidates,
1709            bank,
1710            ..
1711        } => {
1712            data.insert("decision".to_string(), Value::from("inject"));
1713            data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
1714            data.insert(
1715                "selected_kind".to_string(),
1716                Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
1717            );
1718            data.insert(
1719                "candidate_count".to_string(),
1720                Value::from(candidates.len() as u64),
1721            );
1722            data.insert(
1723                "bank_knowledge".to_string(),
1724                Value::from(bank.knowledge as u64),
1725            );
1726            data.insert(
1727                "bank_procedural".to_string(),
1728                Value::from(bank.procedural as u64),
1729            );
1730            data.insert(
1731                "bank_open_subgoals".to_string(),
1732                Value::from(bank.open_subgoals as u64),
1733            );
1734        }
1735        car_memgine::ProactiveMemoryDecision::Silent {
1736            reason,
1737            candidates,
1738            bank,
1739        } => {
1740            data.insert("decision".to_string(), Value::from("silent"));
1741            data.insert("reason".to_string(), Value::from(reason.clone()));
1742            data.insert(
1743                "candidate_count".to_string(),
1744                Value::from(candidates.len() as u64),
1745            );
1746            data.insert(
1747                "bank_knowledge".to_string(),
1748                Value::from(bank.knowledge as u64),
1749            );
1750            data.insert(
1751                "bank_procedural".to_string(),
1752                Value::from(bank.procedural as u64),
1753            );
1754            data.insert(
1755                "bank_open_subgoals".to_string(),
1756                Value::from(bank.open_subgoals as u64),
1757            );
1758        }
1759    }
1760    data
1761}
1762
1763fn proactive_trigger_event_data(
1764    trigger: &car_memgine::ProactiveMemoryTrigger,
1765) -> std::collections::HashMap<String, Value> {
1766    std::collections::HashMap::from([
1767        (
1768            "repeated_failures".to_string(),
1769            Value::from(trigger.repeated_failures as u64),
1770        ),
1771        ("tool_error".to_string(), Value::from(trigger.tool_error)),
1772        (
1773            "explicit_uncertainty".to_string(),
1774            Value::from(trigger.explicit_uncertainty),
1775        ),
1776        (
1777            "high_risk_action".to_string(),
1778            Value::from(trigger.high_risk_action),
1779        ),
1780        (
1781            "context_shift".to_string(),
1782            Value::from(trigger.context_shift),
1783        ),
1784    ])
1785}
1786
1787/// Record one completed model call into the run's event log as
1788/// [`car_eventlog::EventKind::InferenceMetered`].
1789///
1790/// Until this existed the assistant loop wrote **no** inference telemetry, so
1791/// `harness_metrics::compute_harness_metrics` over an assistant journal reported
1792/// zero tokens and zero model calls — which made the whole
1793/// `trajectory_efficiency` token branch of the Evolution Agent's regression gate
1794/// (`car_memgine::harness_evolution`) structurally inert. The data was never
1795/// missing: [`car_inference::InferenceResult`] has carried `usage` and
1796/// `latency_ms` all along and the loop simply dropped them.
1797///
1798/// Uses the log handle the loop already holds (`runtime.log`) — the same
1799/// mechanism `maybe_apply_assistant_proactive_memory` uses for its
1800/// `ProactiveMemoryMaintained` record, and the same `append_metered` contract
1801/// the streaming path in `handler.rs` uses. No new plumbing, no new field on any
1802/// signature.
1803///
1804/// **`usage: None` is recorded as a call with no token metrics, not as zeros.**
1805/// `car-inference` makes `usage` optional precisely so "nobody could count" is
1806/// distinguishable from "this really used no tokens" (Parslee-ai/car#795), and
1807/// `Metrics::latency` leaves the token keys absent from the event data rather
1808/// than writing `0`. The event is still emitted, because a call that happened
1809/// with an uncountable cost is still a call: skipping it would silently
1810/// undercount `model_calls`, the exact number the #813 A/B turns on.
1811///
1812/// `cost_usd` is left `None`: the loop has no price table, and a fabricated 0.0
1813/// would be indistinguishable from a free call. The gate does not read cost.
1814async fn record_inference_metered(runtime: &Runtime, result: &car_inference::InferenceResult) {
1815    let mut data: HashMap<String, Value> = HashMap::new();
1816    data.insert(
1817        "model_id".to_string(),
1818        Value::from(result.served_model_id().to_string()),
1819    );
1820    // Explicit provenance for the absent-vs-zero distinction above, so a
1821    // consumer reading the journal need not infer it from missing keys.
1822    data.insert(
1823        "usage_measured".to_string(),
1824        Value::from(result.usage.is_some()),
1825    );
1826
1827    let metrics = match &result.usage {
1828        Some(u) => car_eventlog::Metrics::inference(u.prompt_tokens, u.completion_tokens, None)
1829            .with_duration(result.latency_ms as f64),
1830        None => car_eventlog::Metrics::latency(result.latency_ms as f64),
1831    };
1832
1833    runtime.log.lock().await.append_metered(
1834        car_eventlog::EventKind::InferenceMetered,
1835        None,
1836        None,
1837        data,
1838        metrics,
1839    );
1840}
1841
1842async fn maybe_apply_assistant_proactive_memory(
1843    cfg: &AssistantConfig,
1844    runtime: &Runtime,
1845    req: &mut GenerateRequest,
1846    messages: &[Message],
1847) {
1848    let Some(memory) = &cfg.proactive_memory else {
1849        return;
1850    };
1851    let query = proactive_query_from_messages(messages);
1852    if query.trim().is_empty() {
1853        return;
1854    }
1855    let mut recent = messages
1856        .iter()
1857        .rev()
1858        .filter_map(message_memory_text)
1859        .take(6)
1860        .collect::<Vec<_>>();
1861    recent.reverse();
1862    let events = {
1863        let log = runtime.log.lock().await;
1864        log.events().to_vec()
1865    };
1866    let (maintenance, decision) = match memory.proactive_intervention(&query, recent, &events).await
1867    {
1868        Ok(out) => out,
1869        Err(e) => {
1870            tracing::debug!(error = %e, "assistant proactive memory pass failed");
1871            return;
1872        }
1873    };
1874    {
1875        let mut log = runtime.log.lock().await;
1876        log.append(
1877            car_eventlog::EventKind::ProactiveMemoryMaintained,
1878            None,
1879            None,
1880            proactive_maintenance_event_data(&maintenance),
1881        );
1882        log.append(
1883            car_eventlog::EventKind::ProactiveMemoryIntervention,
1884            None,
1885            None,
1886            proactive_intervention_event_data(&decision),
1887        );
1888    }
1889    if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
1890        append_context_block(req, "Proactive Memory", &reminder);
1891    }
1892}
1893
1894/// Per-run state for tool-repair learning: which tool failures are still
1895/// unrepaired, and which learned leads have already been offered.
1896///
1897/// This lives in the loop rather than in [`ToolMemory`] because it is run
1898/// state, not learned state — and it cannot be reconstructed from the
1899/// transcript, which records that a call failed but not that a later call was
1900/// the *recovery* for it.
1901#[derive(Default)]
1902struct OpenFailures {
1903    /// The most recent unrepaired failure per tool: its signature, the turn it
1904    /// happened on, and the arguments that failed.
1905    ///
1906    /// One entry per tool, deliberately. A run that fails two ways on the same
1907    /// tool (`missing_target` at turn 1, `timeout` at turn 3) keeps only the
1908    /// later one, so the earlier kind is not learned from that run. The
1909    /// alternative — a queue, and matching a success against the right member —
1910    /// needs a similarity notion this has no way to compute; keeping the most
1911    /// recent biases toward the failure the model was actually working on when
1912    /// it succeeded, which is the one a recovery most likely addressed.
1913    by_tool: HashMap<String, OpenFailure>,
1914    /// Signature keys a learned lead has been offered for at least once this
1915    /// run. Consulted ONLY to gate penalization — a lead stays offered on every
1916    /// turn its failure is open, and that repetition is free because the request
1917    /// context is rebuilt from scratch each turn.
1918    offered: std::collections::HashSet<String>,
1919    /// Signature keys already penalized this run, so one stale lead costs one
1920    /// failure however many times the model retries behind it.
1921    penalized: std::collections::HashSet<String>,
1922}
1923
1924struct OpenFailure {
1925    sig: FailureSignature,
1926    turn: u32,
1927    /// The arguments of the call that failed, so a "recovery" can be required to
1928    /// differ from it.
1929    params: Value,
1930}
1931
1932impl OpenFailures {
1933    fn observe_failure(&mut self, tool: &str, sig: FailureSignature, turn: u32, params: &Value) {
1934        self.by_tool.insert(
1935            tool.to_string(),
1936            OpenFailure {
1937                sig,
1938                turn,
1939                params: params.clone(),
1940            },
1941        );
1942    }
1943
1944    /// A success on `tool` closes its open failure and is credited as the repair
1945    /// — but only when it is plausibly a repair at all. Two conditions, and the
1946    /// second is the one that matters.
1947    ///
1948    /// **Inside the recovery window.** Outside it the failure is forgotten
1949    /// rather than credited: an unrelated call many turns later is not a fix.
1950    ///
1951    /// **Different arguments from the call that failed.** Without this, every
1952    /// routine success on a read-heavy tool harvested whatever failure happened
1953    /// to be open — `web_search` fails, three ordinary searches follow, and the
1954    /// last unrelated query becomes the durable "repair" for
1955    /// `web_search::not_found`. Identical arguments succeeding is a transient,
1956    /// not a repair, and storing it teaches a lead that fixes nothing. Requiring
1957    /// a difference is the cheapest available proxy for "the model changed
1958    /// something", and it is the honest floor: it does not prove the change is
1959    /// what fixed it, only that a change occurred.
1960    ///
1961    /// This matters more than it looks, because the failure side of the ledger
1962    /// is narrow. A lead is penalized only when it was offered and its signature
1963    /// then failed AGAIN in the same run, so a wrong lead that the model quietly
1964    /// works around accrues successes and never a failure. Degradation cannot be
1965    /// the whole answer to mis-crediting; not mis-crediting is.
1966    fn take_recovery(&mut self, tool: &str, turn: u32, params: &Value) -> Option<FailureSignature> {
1967        let open = self.by_tool.remove(tool)?;
1968        if turn.saturating_sub(open.turn) > RECOVERY_WINDOW_TURNS {
1969            return None;
1970        }
1971        (open.params != *params).then_some(open.sig)
1972    }
1973
1974    /// Signatures still unrepaired, for the recall pass. Cloned rather than
1975    /// borrowed so the caller can record what it offered while iterating.
1976    fn pending(&self) -> Vec<FailureSignature> {
1977        self.by_tool.values().map(|open| open.sig.clone()).collect()
1978    }
1979}
1980
1981/// Recall learned repairs into the next model turn.
1982///
1983/// Two sources, in priority order. A lead for a failure that is open *right
1984/// now* is worth the most — the model is holding that problem this turn — so it
1985/// goes first and is never crowded out. Session-start leads (turn 1, keyed on
1986/// the task text) fill whatever room is left; they are speculative by nature,
1987/// so they yield.
1988fn maybe_apply_tool_memory(
1989    cfg: &AssistantConfig,
1990    req: &mut GenerateRequest,
1991    open: &mut OpenFailures,
1992    messages: &[Message],
1993    turns: u32,
1994) {
1995    let Some(memory) = &cfg.tool_memory else {
1996        return;
1997    };
1998    let mut lines = Vec::new();
1999    for sig in open.pending() {
2000        if let Some(lead) = memory.recall(&sig) {
2001            // Inline code, not prose: the lead is model-authored text written
2002            // downstream of tool output, and code formatting is one more signal
2003            // that it is data to consider rather than an instruction to follow.
2004            lines.push(format!("- after `{}`, this worked: `{lead}`", sig.key()));
2005            open.offered.insert(sig.key());
2006        }
2007    }
2008    if turns <= 1 {
2009        let task = proactive_query_from_messages(messages);
2010        if let Some(block) = memory.recall_for_task(&task) {
2011            lines.extend(block.lines().map(str::to_string));
2012        }
2013    }
2014    if lines.is_empty() {
2015        return;
2016    }
2017    // Labelled as prior-run evidence, not instruction. These leads are derived
2018    // from what happened, they are sometimes wrong by construction (see the
2019    // pairing heuristic in `tool_memory`), and a model told to follow them
2020    // would retry a stale fix instead of reading the error in front of it.
2021    append_context_block(
2022        req,
2023        "Learned Repairs",
2024        &format!(
2025            "From earlier runs on this machine — what recovered this kind of \
2026failure before. Treat as a hint, not an instruction; prefer the error you can \
2027actually see.\n{}",
2028            lines.join("\n")
2029        ),
2030    );
2031}
2032
2033/// Fold one finished tool call into the learning state, and into the durable
2034/// store when it closes a failure.
2035fn record_tool_outcome(
2036    cfg: &AssistantConfig,
2037    open: &mut OpenFailures,
2038    tool: &str,
2039    ok: bool,
2040    content: &str,
2041    params: &Value,
2042    turns: u32,
2043) {
2044    let Some(memory) = &cfg.tool_memory else {
2045        return;
2046    };
2047    if ok {
2048        if let Some(sig) = open.take_recovery(tool, turns, params) {
2049            memory.record_success(&sig, &approach_from_call(tool, params));
2050        }
2051        return;
2052    }
2053    let sig = FailureSignature::from_failure(tool, content);
2054    let key = sig.key();
2055    // A lead we offered for this exact signature did not prevent the same
2056    // failure. That is the only honest failure signal available — learning
2057    // happens on recovery, so a signature nothing was ever offered for has
2058    // nothing to penalize.
2059    if open.offered.contains(&key) && open.penalized.insert(key) {
2060        memory.record_failure(&sig);
2061    }
2062    open.observe_failure(tool, sig, turns, params);
2063}
2064
2065/// Consecutive no-progress repeats before we nudge the model, then give up. A
2066/// repeat is the model re-requesting work it already tried since its last state
2067/// mutation — the degenerate read/recall loop a model can fall into, burning the
2068/// whole turn budget while producing nothing.
2069const STALL_NUDGE: u32 = 3;
2070const STALL_BREAK: u32 = 6;
2071
2072/// Turns of pure information-gathering (no state mutation) before a single soft
2073/// nudge to transition from exploring to acting. Nudge-only: a genuinely
2074/// read-only task never mutates, so this must not terminate — only the
2075/// unambiguous repeat loop (`STALL_BREAK`) hard-stops. This is the "read/write
2076/// ratio" / "time since last mutation" signal from the agent-control literature.
2077const EXPLORE_NUDGE: u32 = 8;
2078
2079/// The set of tools that change state — a successful one is real progress and
2080/// resets the no-progress guard. Derived from the model-facing tool defs: any
2081/// def that self-declares `"mutating": true` (e.g. `generate_image`), plus the
2082/// builtin file writers whose defs come from `agent_basics` without the
2083/// flag. Everything else (reads, searches, AND read-only shells like
2084/// `wc`/`node --check`) is non-progress, so probing between reads can't silently
2085/// reset the guard. `shell` is deliberately excluded: a shell that never
2086/// accompanies a file edit isn't moving the task forward, and one that does
2087/// mutate is paired with a write/edit that resets the guard anyway.
2088pub(crate) fn mutating_tool_names(tool_defs: &[Value]) -> std::collections::HashSet<String> {
2089    let mut set: std::collections::HashSet<String> = ["write_file", "edit_file"]
2090        .iter()
2091        .map(|s| s.to_string())
2092        .collect();
2093    for def in tool_defs {
2094        if def
2095            .get("mutating")
2096            .and_then(Value::as_bool)
2097            .unwrap_or(false)
2098        {
2099            if let Some(name) = def.get("name").and_then(Value::as_str) {
2100                set.insert(name.to_string());
2101            }
2102        }
2103    }
2104    set
2105}
2106
2107/// A stable signature of a turn's tool calls (names + arguments; id-independent
2108/// and order-independent) so two turns that request the identical work compare
2109/// equal — the basis for detecting a no-progress repeat.
2110fn tool_calls_signature(calls: &[ToolCall]) -> String {
2111    let mut parts: Vec<String> = calls
2112        .iter()
2113        .map(|c| {
2114            format!(
2115                "{}({})",
2116                c.name,
2117                serde_json::to_string(&c.arguments).unwrap_or_default()
2118            )
2119        })
2120        .collect();
2121    parts.sort();
2122    parts.join("|")
2123}
2124
2125/// What the loop should do after one turn's tool calls, per the no-progress
2126/// guard.
2127#[derive(Debug, PartialEq, Eq)]
2128enum GuardStep {
2129    /// A genuinely new state mutation — real progress; carry on fresh.
2130    Progress,
2131    /// Nothing notable; keep going.
2132    Continue,
2133    /// Spinning without progress — inject a nudge to act or finish this turn.
2134    Nudge,
2135    /// Repeated the same action too many times — stop the run as stalled.
2136    Break,
2137}
2138
2139/// Tracks whether the agent loop is advancing or spinning in place.
2140///
2141/// A turn counts as progress ONLY when a mutating tool succeeds with a
2142/// signature not seen since the last progress. A repeated *identical* call is
2143/// idempotent — re-`remember`ing the same fact, re-writing identical bytes
2144/// changes nothing — so it is NOT progress even for a nominally `"mutating"`
2145/// tool. Counting such repeats as progress was the bug behind the observed
2146/// `remember()` loop that reset the guard every turn and ran to `max_turns`
2147/// instead of tripping `STALL_BREAK`. Non-mutating repeats are unchanged.
2148#[derive(Default)]
2149struct NoProgressGuard {
2150    seen_sigs: std::collections::HashSet<String>,
2151    stall_repeats: u32,
2152    turns_since_mutation: u32,
2153    nudged: bool,
2154}
2155
2156impl NoProgressGuard {
2157    /// Feed one turn: `sig` is this turn's tool-call signature, `mutated_ok`
2158    /// whether a mutating tool succeeded this turn.
2159    fn observe(&mut self, sig: &str, mutated_ok: bool) -> GuardStep {
2160        let sig_is_new = self.seen_sigs.insert(sig.to_string());
2161        if mutated_ok && sig_is_new {
2162            // Real, new mutation: reset — but keep THIS signature so an immediate
2163            // identical repeat next turn still collides (and counts as a stall).
2164            self.seen_sigs.clear();
2165            self.seen_sigs.insert(sig.to_string());
2166            self.stall_repeats = 0;
2167            self.turns_since_mutation = 0;
2168            self.nudged = false;
2169            return GuardStep::Progress;
2170        }
2171        // Non-mutating, OR a repeated identical mutation → no real progress.
2172        self.turns_since_mutation += 1;
2173        if !sig_is_new {
2174            self.stall_repeats += 1;
2175            if self.stall_repeats >= STALL_BREAK {
2176                return GuardStep::Break;
2177            }
2178            if self.stall_repeats >= STALL_NUDGE && !self.nudged {
2179                self.nudged = true;
2180                return GuardStep::Nudge;
2181            }
2182        }
2183        if self.turns_since_mutation >= EXPLORE_NUDGE && !self.nudged {
2184            self.nudged = true;
2185            return GuardStep::Nudge;
2186        }
2187        GuardStep::Continue
2188    }
2189}
2190
2191/// Build a single-action `tool_call` proposal, binding the action id to the
2192/// call id so the result correlates back.
2193/// Build the executable proposal for a tool call.
2194///
2195/// `parameters` is passed separately rather than read off `call.arguments`
2196/// because the loop may have substituted retained-value handles into it (#813).
2197/// Reading the raw arguments here would resolve `$r3` for the approval gate and
2198/// then execute the unresolved literal — the two must not disagree about what
2199/// is being run.
2200fn build_proposal(
2201    source: &str,
2202    call: &ToolCall,
2203    action_id: &str,
2204    parameters: &Value,
2205) -> Result<ActionProposal, String> {
2206    serde_json::from_value(json!({
2207        "source": source,
2208        "actions": [{
2209            "id": action_id,
2210            "type": "tool_call",
2211            "tool": call.name,
2212            "parameters": parameters,
2213        }],
2214    }))
2215    .map_err(|e| format!("malformed proposal: {e}"))
2216}
2217
2218/// Run one assistant generation while forwarding provider retries as they
2219/// happen. The trait callback is `Send` for production engines, but the loop's
2220/// own event adapter need not be: a channel bridges the callback into this
2221/// task, preserving the existing public closure contract.
2222async fn generate_assistant_with_retry_progress(
2223    generator: &dyn TurnGenerator,
2224    request: GenerateRequest,
2225    mut on_retry: impl FnMut(car_inference::InferenceRetryProgress),
2226) -> Result<InferenceResult, AssistantGenerateError> {
2227    let (retry_tx, mut retry_rx) = tokio::sync::mpsc::unbounded_channel();
2228    let mut retry_observer = move |retry| {
2229        let _ = retry_tx.send(retry);
2230    };
2231    let generation = generator.generate_assistant_observed(request, &mut retry_observer);
2232    tokio::pin!(generation);
2233
2234    loop {
2235        tokio::select! {
2236            biased;
2237            Some(retry) = retry_rx.recv() => on_retry(retry),
2238            result = &mut generation => {
2239                while let Ok(retry) = retry_rx.try_recv() {
2240                    on_retry(retry);
2241                }
2242                return result;
2243            }
2244        }
2245    }
2246}
2247
2248/// Run the assistant loop to a terminal outcome, mutating `messages` (which must
2249/// already carry the system + first user turn) and streaming progress via
2250/// `emit`. Reusable across one-shot, REPL, and per-chat-turn.
2251pub async fn run_assistant_loop(
2252    generator: &dyn TurnGenerator,
2253    runtime: &Runtime,
2254    cfg: &AssistantConfig,
2255    messages: &mut Vec<Message>,
2256    emit: impl FnMut(AssistantEvent),
2257) -> AssistantOutcome {
2258    let never = std::sync::atomic::AtomicBool::new(false);
2259    run_assistant_loop_cancellable(generator, runtime, cfg, messages, &never, None, None, emit)
2260        .await
2261}
2262
2263/// Same as [`run_assistant_loop`], but checks `cancel` before each turn so the
2264/// `agent.chat.cancel` path can interrupt a running turn between model calls,
2265/// and consults `approval` (if any) before running a `gated_tools` action.
2266pub async fn run_assistant_loop_cancellable(
2267    generator: &dyn TurnGenerator,
2268    runtime: &Runtime,
2269    cfg: &AssistantConfig,
2270    messages: &mut Vec<Message>,
2271    cancel: &std::sync::atomic::AtomicBool,
2272    approval: Option<&dyn ApprovalGate>,
2273    images: Option<&[ContentBlock]>,
2274    emit: impl FnMut(AssistantEvent),
2275) -> AssistantOutcome {
2276    run_assistant_loop_cancellable_in_session(
2277        generator, runtime, cfg, messages, cancel, approval, images, None, emit,
2278    )
2279    .await
2280}
2281
2282/// Session-aware variant of [`run_assistant_loop_cancellable`]. A caller that
2283/// multiplexes conversations passes the Runtime session id so stateful tool
2284/// guards (notably read-before-edit) stay isolated between conversations.
2285pub async fn run_assistant_loop_cancellable_in_session(
2286    generator: &dyn TurnGenerator,
2287    runtime: &Runtime,
2288    cfg: &AssistantConfig,
2289    messages: &mut Vec<Message>,
2290    cancel: &std::sync::atomic::AtomicBool,
2291    approval: Option<&dyn ApprovalGate>,
2292    images: Option<&[ContentBlock]>,
2293    runtime_session_id: Option<&str>,
2294    emit: impl FnMut(AssistantEvent),
2295) -> AssistantOutcome {
2296    run_assistant_loop_cancellable_in_session_durable(
2297        generator,
2298        runtime,
2299        cfg,
2300        messages,
2301        cancel,
2302        approval,
2303        images,
2304        runtime_session_id,
2305        None,
2306        None,
2307        true,
2308        emit,
2309    )
2310    .await
2311}
2312
2313/// Durable supervised-session variant. The checkpoint sink is invoked after
2314/// every mutation of the exact model-facing transcript, including compaction,
2315/// assistant tool calls, refusals, and tool results.
2316pub async fn run_assistant_loop_cancellable_in_session_durable(
2317    generator: &dyn TurnGenerator,
2318    runtime: &Runtime,
2319    cfg: &AssistantConfig,
2320    messages: &mut Vec<Message>,
2321    cancel: &std::sync::atomic::AtomicBool,
2322    approval: Option<&dyn ApprovalGate>,
2323    images: Option<&[ContentBlock]>,
2324    runtime_session_id: Option<&str>,
2325    durable_session_id: Option<&str>,
2326    durability: Option<&dyn super::governance::AssistantDurability>,
2327    redrive_ungrounded_summary: bool,
2328    mut emit: impl FnMut(AssistantEvent),
2329) -> AssistantOutcome {
2330    use std::sync::atomic::Ordering;
2331    let tools = if cfg.tools.is_empty() {
2332        None
2333    } else {
2334        Some(cfg.tools.clone())
2335    };
2336    let mut tools_called: Vec<String> = Vec::new();
2337    // Restored transcripts carry the authoritative tool-call/result pairs from
2338    // earlier process lifetimes. Seed grounding from them so a continuity turn
2339    // can cite prior evidence without rerunning completed actions.
2340    let mut tool_receipts: Vec<AssistantToolReceipt> = transcript_tool_receipts(messages);
2341    // Where this invocation's own receipts begin. Everything before it is
2342    // replayed evidence; see `AssistantOutcome::prior_receipts`.
2343    let prior_receipts = tool_receipts.len();
2344    // Retained tool results for this run (#813). Constructed unconditionally
2345    // and left empty when the feature is off, so the off path allocates one
2346    // empty map and takes no other behavioral difference.
2347    let mut values = super::value_store::SessionValues::new();
2348    let mut last_text = String::new();
2349    let mut last_model = String::new();
2350    let mut models_served = Vec::new();
2351    // Keep correlation ids unique across successive chat turns in the same
2352    // transcript; the invocation-local `turns` counter restarts at one.
2353    // Resolved ONCE, so a compaction inside this invocation cannot renumber
2354    // ids mid-run either.
2355    let prior_assistant_turns = transcript_turn_offset(messages);
2356    let mut turns = 0u32;
2357    let mut turns_completed = 0u32;
2358    let mut claim_corrections = 0u8;
2359    // The model's window, resolved once (the model is fixed for the run). Used
2360    // to bound the growing message history each turn; 0 (unknown) disables it.
2361    // A caller's override tightens it; one above the known window is clamped
2362    // (see `resolve_context_window`).
2363    let registry_window = cfg
2364        .model
2365        .as_deref()
2366        .map(|m| generator.context_window(m))
2367        .unwrap_or(0);
2368    let (context_window, window_advisory) =
2369        resolve_context_window(cfg.context_window_override, registry_window);
2370    if let Some(advisory) = window_advisory {
2371        tracing::warn!(
2372            requested = cfg.context_window_override,
2373            registry_window,
2374            "{advisory}"
2375        );
2376        emit(AssistantEvent::Text(format!(
2377            "[context window: {advisory}]"
2378        )));
2379    }
2380    // What the per-message estimate cannot see, plus the provider's own count
2381    // of the last request once there is one (see `PromptMeasure`). The tool
2382    // definitions ride on every turn; their estimate is the same shared
2383    // chars/4 the inference layer's guard uses.
2384    let mut prompt_measure = PromptMeasure {
2385        fixed_overhead: car_inference::media_tokens::tool_defs_tokens(&cfg.tools),
2386        reported: None,
2387    };
2388    // Tools whose success counts as progress (resets the no-progress guard),
2389    // derived from the advertised defs — so a capability tool that self-declares
2390    // `"mutating": true` (e.g. generate_image) is recognized without editing the
2391    // loop.
2392    let mutating_tools = mutating_tool_names(&cfg.tools);
2393    // The advertised names, for the delegate child's execution-time subset
2394    // check, and whether this run offers `delegate` at all.
2395    let advertised_names: std::collections::HashSet<String> = cfg
2396        .tools
2397        .iter()
2398        .filter_map(|d| d.get("name").and_then(Value::as_str))
2399        .map(str::to_string)
2400        .collect();
2401    let delegate_advertised = advertised_names.contains(DELEGATE_TOOL);
2402    // Run-level delegation budget (see `DelegateBudget`).
2403    let delegate_budget = cfg.delegate_budget.unwrap_or_default();
2404    let mut delegations_spawned: u32 = 0;
2405    let mut child_turns_used: u32 = 0;
2406    // Tool-result provenance labels, resolved once for the run. The fallback is
2407    // the built-in table, not an empty one: an empty map classifies every result
2408    // as internal, which would leave the marking switched off by omission
2409    // (car#723).
2410    let builtin_labels;
2411    let tool_labels = match &cfg.tool_labels {
2412        Some(m) => m,
2413        None => {
2414            builtin_labels = builtin_tool_labels();
2415            &builtin_labels
2416        }
2417    };
2418    // No-progress guard: tracks signatures of work tried since the last real
2419    // state mutation (a signature reappearing is a stall) plus turns spent
2420    // without changing anything. A repeated identical call is never progress,
2421    // even to a mutating tool — see NoProgressGuard.
2422    let mut guard = NoProgressGuard::default();
2423    // Tool-repair learning state for this run (`tool_memory`). Inert unless a
2424    // surface opted in via `AssistantConfig::tool_memory`.
2425    let mut open_failures = OpenFailures::default();
2426
2427    while turns < cfg.max_turns {
2428        if cancel.load(Ordering::Relaxed) {
2429            return AssistantOutcome {
2430                status: "cancelled",
2431                summary: "cancelled".to_string(),
2432                turns,
2433                turns_completed,
2434                tools_called,
2435                tool_receipts,
2436                prior_receipts,
2437                models_served: models_served.clone(),
2438                model_used: last_model.clone(),
2439                auth_required: None,
2440                failure_cause: None,
2441            };
2442        }
2443        turns += 1;
2444
2445        // Keep the running conversation within the model's context window so a
2446        // long tool-heavy run never overflows it (which degrades the model and
2447        // can truncate the original task provider-side).
2448        let before_compaction = messages.clone();
2449        compact_history_measured(messages, context_window, prompt_measure);
2450        if before_compaction != *messages {
2451            // The reported count covered the pre-compaction history; the next
2452            // call reports afresh.
2453            prompt_measure.reported = None;
2454            if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2455                if let Err(e) = store
2456                    .checkpoint(session_id, messages, "history_compacted", None)
2457                    .await
2458                {
2459                    let msg = format!("durable checkpoint failed after compaction: {e}");
2460                    emit(AssistantEvent::Error(msg.clone()));
2461                    return AssistantOutcome {
2462                        status: "error",
2463                        summary: msg,
2464                        turns,
2465                        turns_completed,
2466                        tools_called,
2467                        tool_receipts,
2468                        prior_receipts,
2469                        models_served: models_served.clone(),
2470                        model_used: last_model,
2471                        auth_required: None,
2472                        failure_cause: None,
2473                    };
2474                }
2475            }
2476        }
2477
2478        let mut req = GenerateRequest {
2479            prompt: String::new(),
2480            model: cfg.model.clone(),
2481            params: GenerateParams {
2482                temperature: 0.0,
2483                strict_model: cfg.strict_model,
2484                ..Default::default()
2485            },
2486            context: None,
2487            context_stable_prefix: None,
2488            tools: tools.clone(),
2489            // Attach any images to the first request (they belong to the user's
2490            // latest message); later turns are tool-result follow-ups.
2491            images: if turns == 1 {
2492                images.map(|imgs| imgs.to_vec())
2493            } else {
2494                None
2495            },
2496            messages: Some(messages.clone()),
2497            cache_control: false,
2498            // Only on tool-less turns: a JSON-constrained request suppresses
2499            // tool use on real providers, so a turn that offers tools stays
2500            // unconstrained and the final answer is checked (and repaired
2501            // once, without tools) below.
2502            response_format: if tools.is_none() {
2503                cfg.response_format.clone()
2504            } else {
2505                None
2506            },
2507            intent: None,
2508            client_ref: None,
2509            expected_row_digest: None,
2510            expected_catalog_revision: None,
2511            caller: None,
2512        };
2513        maybe_apply_assistant_proactive_memory(cfg, runtime, &mut req, messages).await;
2514        maybe_apply_tool_memory(cfg, &mut req, &mut open_failures, messages, turns);
2515
2516        // Per-turn state block (#814 items 2-3). Rendered fresh every turn from
2517        // live state and appended to the request copy, so durable state reaches
2518        // the model whether or not it thinks to ask — the recall-discipline
2519        // dependency the issue is about. Skipped entirely when there is nothing
2520        // to say, so a run with no plan and no writes costs no tokens and no
2521        // cache churn.
2522        //
2523        // Carries the task list and the facts written this run. It deliberately
2524        // does NOT carry the working directory or the approval tier, which #814
2525        // also lists, because neither is live state: `root` is fixed at executor
2526        // construction and the standing tier at bind time, and both already reach
2527        // the model in the system prompt's `Environment:` sentence — which
2528        // compaction pins, so it can never be evicted. Repeating a constant in
2529        // the tail would re-send it every turn for no new information and create
2530        // a second copy free to drift from the substrate's own description.
2531        let todo_render = match &cfg.todos {
2532            Some(todos) => todos.lock().await.render(),
2533            None => None,
2534        };
2535        if let Some(block) = render_state_block(todo_render, &recent_fact_subjects(&tool_receipts))
2536        {
2537            if let Some(msgs) = req.messages.as_mut() {
2538                append_state_block(msgs, &block);
2539            }
2540        }
2541        // How many leading messages of the LIVE history this request carries
2542        // — the index the provider's reported prompt size will be attributed
2543        // to. Captured after the state block and memory context are attached:
2544        // both land on the request's own copy (`req.messages`), never on
2545        // `messages`, so their tokens are part of the reported count without
2546        // being messages the next turn can index. The ratio in
2547        // `compact_history_measured` absorbs them.
2548        let request_covers = messages.len();
2549
2550        let requested_model = req.model.clone().unwrap_or_else(|| "(router)".to_string());
2551        emit(AssistantEvent::InferenceStarted {
2552            model: requested_model,
2553            attempt: 1,
2554            turn: turns,
2555        });
2556        let mut result = match generate_assistant_with_retry_progress(generator, req, |retry| {
2557            emit(AssistantEvent::InferenceRetry {
2558                model: retry.model,
2559                attempt: retry.attempt,
2560                reason: retry.reason.to_string(),
2561                backoff_ms: retry.backoff_ms,
2562            });
2563        })
2564        .await
2565        {
2566            Ok(r) => {
2567                turns_completed += 1;
2568                r
2569            }
2570            Err(e) => {
2571                // An account failure the person can act on ends the turn with a
2572                // remedy instead of a transport error. No `ModelServed` is
2573                // emitted for this turn — nothing served it — and no `Error`
2574                // follows, so the chat service's terminal frame is the
2575                // `auth_required` one and a host never sees both.
2576                if let Some(reason) = auth_required_reason(&e) {
2577                    let message = reason.remedy().to_string();
2578                    emit(AssistantEvent::AuthRequired {
2579                        reason,
2580                        message: message.clone(),
2581                    });
2582                    return AssistantOutcome {
2583                        status: "auth_required",
2584                        summary: message,
2585                        turns,
2586                        turns_completed,
2587                        tools_called,
2588                        tool_receipts,
2589                        prior_receipts,
2590                        models_served: models_served.clone(),
2591                        model_used: last_model.clone(),
2592                        auth_required: Some(reason),
2593                        failure_cause: None,
2594                    };
2595                }
2596                let failure_cause = generation_failure_cause(&e);
2597                let msg = format!("inference failed: {e}");
2598                emit(AssistantEvent::Error(msg.clone()));
2599                return AssistantOutcome {
2600                    status: "error",
2601                    summary: msg,
2602                    turns,
2603                    turns_completed,
2604                    tools_called,
2605                    tool_receipts,
2606                    prior_receipts,
2607                    models_served: models_served.clone(),
2608                    model_used: last_model.clone(),
2609                    auth_required: None,
2610                    failure_cause: Some(failure_cause),
2611                };
2612            }
2613        };
2614        // Meter the call that just returned. Placed immediately after a
2615        // successful `generate` and before every early return below, so each
2616        // COMPLETED model call is counted exactly once no matter which of those
2617        // paths the turn takes.
2618        //
2619        // A call that *failed* is NOT counted: the `Err` arm above returns from
2620        // the loop before reaching this line. So `model_calls` is a count of
2621        // completed calls, never of attempts — a harness that retries a failing
2622        // provider ten times and gives up records zero. Read it alongside the
2623        // run's terminal status; do not read it as "requests issued".
2624        record_inference_metered(runtime, &result).await;
2625        let attribution = AssistantModelAttribution {
2626            model_id: result.served_model_id().to_string(),
2627            local_last_resort: result.local_last_resort,
2628        };
2629        emit(AssistantEvent::ModelServed {
2630            model_id: attribution.model_id.clone(),
2631            local_last_resort: attribution.local_last_resort,
2632        });
2633        models_served.push(attribution);
2634        // Ground truth for the next turn's compaction decision. All three
2635        // input buckets: Anthropic reports the cached prefix separately from
2636        // `prompt_tokens`, and the window holds the sum.
2637        if let Some(u) = &result.usage {
2638            let input = u.prompt_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens;
2639            if input > 0 {
2640                prompt_measure.reported = Some((input as usize, request_covers));
2641            }
2642        }
2643        // Strip any leaked model reasoning-channel prefix (e.g. gemma-4's
2644        // `thought`) from the answer at this choke point — the streamed
2645        // per-turn generate can bypass the inference-layer tool-call parsers, so
2646        // clean it here so no chat bubble ever shows the model's reasoning label
2647        // as the answer (table stakes, matching Claude Code / ChatGPT).
2648        result.text = car_inference::tasks::generate::strip_leaked_reasoning(&result.text);
2649        last_model = result.served_model_id().to_string();
2650
2651        // No tool calls → the model's text is the final answer.
2652        if result.tool_calls.is_empty() {
2653            last_text = result.text.clone();
2654            let ungrounded = ungrounded_summary_claims(&last_text, &tool_receipts);
2655            if redrive_ungrounded_summary && !ungrounded.is_empty() {
2656                result.append_assistant_history(messages, vec![]);
2657                if claim_corrections < 2 && turns < cfg.max_turns {
2658                    claim_corrections += 1;
2659                    messages.push(Message::User {
2660                        content: format!(
2661                            "Evidence check rejected the draft's unsupported operational claim(s): {}. \
2662                             Rewrite the answer using only claims supported by successful transcript \
2663                             tool receipts. Preserve useful source findings, explicitly mark missing \
2664                             live evidence, and do not rerun completed actions merely to support prose.",
2665                            ungrounded.join(", ")
2666                        ),
2667                    });
2668                    if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2669                        if let Err(e) = store
2670                            .checkpoint(session_id, messages, "ungrounded_summary_redrive", None)
2671                            .await
2672                        {
2673                            let msg =
2674                                format!("durable checkpoint failed before claim correction: {e}");
2675                            emit(AssistantEvent::Error(msg.clone()));
2676                            return AssistantOutcome {
2677                                status: "error",
2678                                summary: msg,
2679                                turns,
2680                                turns_completed,
2681                                tools_called,
2682                                tool_receipts,
2683                                prior_receipts,
2684                                models_served: models_served.clone(),
2685                                model_used: last_model,
2686                                auth_required: None,
2687                                failure_cause: None,
2688                            };
2689                        }
2690                    }
2691                    continue;
2692                }
2693                let summary = annotate_summary_with_claim_note(&last_text, &ungrounded);
2694                emit(AssistantEvent::Error(summary.clone()));
2695                return AssistantOutcome {
2696                    status: "error",
2697                    summary,
2698                    turns,
2699                    turns_completed,
2700                    tools_called,
2701                    tool_receipts,
2702                    prior_receipts,
2703                    models_served: models_served.clone(),
2704                    model_used: last_model,
2705                    auth_required: None,
2706                    failure_cause: None,
2707                };
2708            }
2709            // One-shot format repair. Tool turns are never JSON-constrained
2710            // (a constrained request suppresses tool use), so on a run with
2711            // tools this check is what makes the CONTRACT hold. If the final
2712            // text is not the requested shape, re-ask once with the draft in
2713            // the transcript, no tools, and the format set — a turn that
2714            // cannot be anything but the answer. A final text that already
2715            // parses costs no extra call. The repair is announced
2716            // as a Text event so a `--json` consumer sees it happened; it is
2717            // still one repair, never a loop (a second miss is reported, not
2718            // retried).
2719            // Whether the final assistant message is already in `messages`
2720            // (the repair path appends the draft before re-asking).
2721            let mut final_appended = false;
2722            if let Some(format) = cfg.response_format.as_ref().filter(|f| {
2723                !final_text_matches_format(&last_text, f, cfg.response_format_validator.as_ref())
2724            }) {
2725                emit(AssistantEvent::Text(FORMAT_REPAIR_NOTICE.to_string()));
2726                result.append_assistant_history(messages, vec![]);
2727                messages.push(Message::User {
2728                    content: format_repair_nudge(format).to_string(),
2729                });
2730                let repair = GenerateRequest {
2731                    prompt: String::new(),
2732                    model: cfg.model.clone(),
2733                    params: GenerateParams {
2734                        temperature: 0.0,
2735                        strict_model: cfg.strict_model,
2736                        ..Default::default()
2737                    },
2738                    context: None,
2739                    context_stable_prefix: None,
2740                    tools: None,
2741                    images: None,
2742                    messages: Some(messages.clone()),
2743                    cache_control: false,
2744                    response_format: Some(format.clone()),
2745                    intent: None,
2746                    client_ref: None,
2747                    expected_row_digest: None,
2748                    expected_catalog_revision: None,
2749                    caller: None,
2750                };
2751                // The typed seam here too, so the repair turn cannot silently
2752                // keep the old string path — but the OUTCOME is deliberately
2753                // unchanged: an auth failure on the repair call still returns
2754                // the draft answer per `docs/car-do-json.md`. The primary call
2755                // already succeeded and emitted its `ModelServed`; retracting
2756                // that answer to show a sign-in card would throw away work the
2757                // model did, and the NEXT turn produces the `auth_required`
2758                // anyway.
2759                let requested_model = repair
2760                    .model
2761                    .clone()
2762                    .unwrap_or_else(|| "(router)".to_string());
2763                emit(AssistantEvent::InferenceStarted {
2764                    model: requested_model,
2765                    attempt: 1,
2766                    turn: turns,
2767                });
2768                match generate_assistant_with_retry_progress(generator, repair, |retry| {
2769                    emit(AssistantEvent::InferenceRetry {
2770                        model: retry.model,
2771                        attempt: retry.attempt,
2772                        reason: retry.reason.to_string(),
2773                        backoff_ms: retry.backoff_ms,
2774                    });
2775                })
2776                .await
2777                {
2778                    Ok(mut repaired) => {
2779                        record_inference_metered(runtime, &repaired).await;
2780                        let attribution = AssistantModelAttribution {
2781                            model_id: repaired.served_model_id().to_string(),
2782                            local_last_resort: repaired.local_last_resort,
2783                        };
2784                        emit(AssistantEvent::ModelServed {
2785                            model_id: attribution.model_id.clone(),
2786                            local_last_resort: attribution.local_last_resort,
2787                        });
2788                        models_served.push(attribution);
2789                        repaired.text =
2790                            car_inference::tasks::generate::strip_leaked_reasoning(&repaired.text);
2791                        if !final_text_matches_format(
2792                            &repaired.text,
2793                            format,
2794                            cfg.response_format_validator.as_ref(),
2795                        ) {
2796                            emit(AssistantEvent::Text(
2797                                FORMAT_REPAIR_STILL_INVALID.to_string(),
2798                            ));
2799                        }
2800                        last_model = repaired.served_model_id().to_string();
2801                        last_text = repaired.text.clone();
2802                        result = repaired;
2803                    }
2804                    Err(e) => {
2805                        // Keep the draft: it is the model's answer, and the
2806                        // provider's refusal to enforce a format does not
2807                        // unmake it. Drop the nudge so the durable transcript
2808                        // ends on the answer, not on a question nobody
2809                        // answered.
2810                        if matches!(messages.last(), Some(Message::User { content }) if content == format_repair_nudge(format))
2811                        {
2812                            messages.pop();
2813                        }
2814                        emit(AssistantEvent::Text(format!(
2815                            "{FORMAT_REPAIR_FAILED_PREFIX} {e}; returning the draft answer as-is]"
2816                        )));
2817                        final_appended = true;
2818                    }
2819                }
2820            }
2821            if !final_appended {
2822                result.append_assistant_history(messages, vec![]);
2823            }
2824            if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2825                if let Err(e) = store
2826                    .checkpoint(session_id, messages, "assistant_final", None)
2827                    .await
2828                {
2829                    let msg = format!("durable checkpoint failed after assistant response: {e}");
2830                    emit(AssistantEvent::Error(msg.clone()));
2831                    return AssistantOutcome {
2832                        status: "error",
2833                        summary: msg,
2834                        turns,
2835                        turns_completed,
2836                        tools_called,
2837                        tool_receipts,
2838                        prior_receipts,
2839                        models_served: models_served.clone(),
2840                        model_used: last_model,
2841                        auth_required: None,
2842                        failure_cause: None,
2843                    };
2844                }
2845            }
2846            // Record why the loop stopped. On this ungrounded default path an
2847            // empty-tool-calls turn is treated as success even when the model was
2848            // truncated mid-answer — capture the truncation signal so a false
2849            // completion is diagnosable (docs/audits/car-tracing-design-2026-07-07).
2850            runtime
2851                .record_turn_completed(
2852                    "empty_tool_calls",
2853                    result.stop_reason.as_deref(),
2854                    result.was_truncated(),
2855                    turns,
2856                    &last_model,
2857                )
2858                .await;
2859            emit(AssistantEvent::Done {
2860                text: last_text.clone(),
2861            });
2862            return AssistantOutcome {
2863                status: "success",
2864                summary: last_text,
2865                turns,
2866                turns_completed,
2867                tools_called,
2868                tool_receipts,
2869                prior_receipts,
2870                models_served: models_served.clone(),
2871                model_used: last_model.clone(),
2872                auth_required: None,
2873                failure_cause: None,
2874            };
2875        }
2876
2877        if !result.text.trim().is_empty() {
2878            last_text = result.text.clone();
2879            emit(AssistantEvent::Text(result.text.clone()));
2880        }
2881
2882        // Provider ids belong to the inference protocol and must be echoed back
2883        // unchanged, but they are not a safe host correlation key: local-model
2884        // adapters may repeat them. Fill only missing provider ids here. The
2885        // loop generates a separate turn/sequence key below for proposals,
2886        // approvals, receipts, and wire events.
2887        let mut calls = result.tool_calls.clone();
2888        for (i, call) in calls.iter_mut().enumerate() {
2889            if call.id.is_none() {
2890                let sequence = u32::try_from(i + 1).unwrap_or(u32::MAX);
2891                let transcript_turn = prior_assistant_turns.saturating_add(turns);
2892                call.id = Some(format!("provider_turn_{transcript_turn}_call_{sequence}"));
2893            }
2894        }
2895
2896        result.append_assistant_history(messages, calls.clone());
2897        if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
2898            if let Err(e) = store
2899                .checkpoint(session_id, messages, "assistant_tool_calls", None)
2900                .await
2901            {
2902                let msg = format!("durable checkpoint failed before tool dispatch: {e}");
2903                emit(AssistantEvent::Error(msg.clone()));
2904                return AssistantOutcome {
2905                    status: "error",
2906                    summary: msg,
2907                    turns,
2908                    turns_completed,
2909                    tools_called,
2910                    tool_receipts,
2911                    prior_receipts,
2912                    models_served: models_served.clone(),
2913                    model_used: last_model,
2914                    auth_required: None,
2915                    failure_cause: None,
2916                };
2917            }
2918        }
2919
2920        // The no-progress guard runs AFTER execution (below), so it can key on
2921        // whether a mutation actually SUCCEEDED — a repeatedly-*failing* write
2922        // (bad path, denied) is not progress, and resetting on the mere request
2923        // would let "40 failed writes" evade the guard.
2924        let mut mutated_ok = false;
2925
2926        // Execute each call in emitted order (avoid the DAG racing same-turn
2927        // filesystem effects like mkdir-then-write).
2928        for (index, call) in calls.iter().enumerate() {
2929            let provider_id = call.id.clone().expect("provider ids assigned above");
2930            let sequence = u32::try_from(index + 1).unwrap_or(u32::MAX);
2931            let transcript_turn = prior_assistant_turns.saturating_add(turns);
2932            let id = format!("turn_{transcript_turn}_call_{sequence}");
2933            emit(AssistantEvent::ToolCall {
2934                call_id: id.clone(),
2935                sequence,
2936                name: call.name.clone(),
2937                params: serde_json::to_value(&call.arguments).unwrap_or_default(),
2938            });
2939
2940            // Cancellation may land after generation but before dispatch. Once
2941            // a request row was emitted it must receive a matching terminal row
2942            // rather than hanging forever in the host transcript.
2943            if cancel.load(Ordering::Relaxed) {
2944                let raw_content = json!({ "error": "cancelled before tool dispatch" }).to_string();
2945                let content = cap(raw_content.clone());
2946                tool_receipts.push(AssistantToolReceipt {
2947                    tool: call.name.clone(),
2948                    call_id: Some(id.clone()),
2949                    sequence: Some(sequence),
2950                    ok: false,
2951                    params: serde_json::to_value(&call.arguments).unwrap_or_default(),
2952                    result: Some(raw_content.clone()),
2953                    via: None,
2954                });
2955                emit(AssistantEvent::ToolResult {
2956                    call_id: id.clone(),
2957                    sequence,
2958                    name: call.name.clone(),
2959                    ok: false,
2960                    content: raw_content,
2961                });
2962                messages.push(Message::ToolResult {
2963                    tool_use_id: provider_id.clone(),
2964                    content,
2965                    provenance: Provenance::Internal,
2966                });
2967                return AssistantOutcome {
2968                    status: "cancelled",
2969                    summary: "cancelled".to_string(),
2970                    turns,
2971                    turns_completed,
2972                    tools_called,
2973                    tool_receipts,
2974                    prior_receipts,
2975                    models_served: models_served.clone(),
2976                    model_used: last_model,
2977                    auth_required: None,
2978                    failure_cause: None,
2979                };
2980            }
2981
2982            // Per-agent approval gate. The policy (when set) decides allow /
2983            // require-approval / deny per the running agent's posture; otherwise
2984            // the standing-tier `gated_tools` list applies (unchanged behavior).
2985            let mut params_val = serde_json::to_value(&call.arguments).unwrap_or_default();
2986            // Resolve `$rN` handles BEFORE anything inspects the parameters
2987            // (#813). Ordering is load bearing three times over: the approval
2988            // policy and the human gate must see the REAL arguments, or a
2989            // reference becomes a way to get an unreviewed value past review;
2990            // and `car-validator` checks against the tool's JSON Schema, where
2991            // `"$r3"` is a string in a slot that may demand an array — resolving
2992            // first keeps every schema unweakened instead of teaching all of
2993            // them to admit a reference form.
2994            if cfg.value_store_previews {
2995                let resolved = values.resolve_refs(&mut params_val);
2996                if !resolved.is_empty() {
2997                    tracing::debug!(
2998                        tool = %call.name,
2999                        handles = ?resolved,
3000                        "resolved retained-value references in tool arguments"
3001                    );
3002                }
3003            }
3004            let params_val = params_val;
3005            let posture = match &cfg.approval_policy {
3006                Some(policy) => policy(&call.name, &params_val),
3007                None => {
3008                    if cfg.gated_tools.iter().any(|t| t == &call.name) {
3009                        ToolApprovalDecision::RequireApproval
3010                    } else {
3011                        ToolApprovalDecision::Allow
3012                    }
3013                }
3014            };
3015            // A delegate child may only call what it was granted (see
3016            // `AssistantConfig::refuse_unadvertised_tools`). Checked before
3017            // the gate so an ungranted tool never reaches an approver either.
3018            let posture = if cfg.refuse_unadvertised_tools && !advertised_names.contains(&call.name)
3019            {
3020                ToolApprovalDecision::Deny(format!(
3021                    "tool '{}' is not granted to this delegate; use only: {}",
3022                    call.name,
3023                    advertised_names
3024                        .iter()
3025                        .cloned()
3026                        .collect::<Vec<_>>()
3027                        .join(", ")
3028                ))
3029            } else {
3030                posture
3031            };
3032            let needs_approval = matches!(&posture, ToolApprovalDecision::RequireApproval);
3033
3034            let refusal: Option<String> = match posture {
3035                ToolApprovalDecision::Allow => None,
3036                ToolApprovalDecision::Deny(reason) => Some(reason),
3037                ToolApprovalDecision::RequireApproval => {
3038                    let decision = match approval {
3039                        Some(gate) => gate.request_action(&id, &call.name, &params_val).await,
3040                        None => ApprovalDecision::Denied(format!(
3041                            "'{}' needs approval: re-run with --full-access to allow it on this host, \
3042                             or use the default sandbox where edits are isolated",
3043                            call.name
3044                        )),
3045                    };
3046                    match decision {
3047                        ApprovalDecision::Approved => None,
3048                        ApprovalDecision::Denied(reason) => Some(reason),
3049                    }
3050                }
3051            };
3052            if let Some(reason) = refusal {
3053                let content = cap(json!({ "error": reason }).to_string());
3054                tool_receipts.push(AssistantToolReceipt {
3055                    tool: call.name.clone(),
3056                    call_id: Some(id.clone()),
3057                    sequence: Some(sequence),
3058                    ok: false,
3059                    params: params_val.clone(),
3060                    result: Some(content.clone()),
3061                    via: None,
3062                });
3063                emit(AssistantEvent::ToolResult {
3064                    call_id: id.clone(),
3065                    sequence,
3066                    name: call.name.clone(),
3067                    ok: false,
3068                    content: content.clone(),
3069                });
3070                messages.push(Message::ToolResult {
3071                    tool_use_id: provider_id.clone(),
3072                    content,
3073                    // A refusal the runtime itself wrote — never fetched.
3074                    provenance: Provenance::Internal,
3075                });
3076                if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3077                    if let Err(e) = store
3078                        .checkpoint(session_id, messages, "tool_refused", None)
3079                        .await
3080                    {
3081                        let msg = format!("durable checkpoint failed after refusal: {e}");
3082                        emit(AssistantEvent::Error(msg.clone()));
3083                        return AssistantOutcome {
3084                            status: "error",
3085                            summary: msg,
3086                            turns,
3087                            turns_completed,
3088                            tools_called,
3089                            tool_receipts,
3090                            prior_receipts,
3091                            models_served: models_served.clone(),
3092                            model_used: last_model,
3093                            auth_required: None,
3094                            failure_cause: None,
3095                        };
3096                    }
3097                }
3098                continue;
3099            }
3100
3101            // Loop-intercepted sub-agent. Only when the parent advertised it
3102            // — a hallucinated `delegate` call on a run that never offered
3103            // one is an unknown tool like any other, not a free sub-agent.
3104            if delegate_advertised && call.name == DELEGATE_TOOL {
3105                let goal_brief = params_val
3106                    .get("goal")
3107                    .and_then(Value::as_str)
3108                    .unwrap_or_default()
3109                    .replace('\n', " ");
3110                let goal_brief: String = goal_brief.chars().take(80).collect();
3111                let over_budget = delegations_spawned >= delegate_budget.max_delegations
3112                    || child_turns_used >= delegate_budget.max_child_turns;
3113                let done = if over_budget {
3114                    DelegateOutcome {
3115                        ok: false,
3116                        content: cap(json!({
3117                            "error": format!(
3118                                "delegation budget exhausted ({delegations_spawned} delegations / \
3119                                 {child_turns_used} child turns used; limits {} / {}) — finish with \
3120                                 what you have",
3121                                delegate_budget.max_delegations, delegate_budget.max_child_turns
3122                            )
3123                        })
3124                        .to_string()),
3125                        turns: 0,
3126                        external: false,
3127                        receipts: Vec::new(),
3128                        spawned: false,
3129                    }
3130                } else {
3131                    run_delegate(
3132                        generator,
3133                        runtime,
3134                        cfg,
3135                        messages,
3136                        &params_val,
3137                        cancel,
3138                        approval,
3139                        runtime_session_id,
3140                        redrive_ungrounded_summary,
3141                        tool_labels,
3142                    )
3143                    .await
3144                };
3145                if done.spawned {
3146                    delegations_spawned += 1;
3147                    child_turns_used = child_turns_used.saturating_add(done.turns);
3148                }
3149                emit(AssistantEvent::Text(format!(
3150                    "[delegate: {goal_brief} — {} turns, {}]",
3151                    done.turns,
3152                    if done.ok { "ok" } else { "error" }
3153                )));
3154                if done.ok {
3155                    tools_called.push(call.name.clone());
3156                    if mutating_tools.contains(&call.name) {
3157                        mutated_ok = true;
3158                    }
3159                }
3160                tool_receipts.push(AssistantToolReceipt {
3161                    tool: call.name.clone(),
3162                    call_id: Some(id.clone()),
3163                    sequence: Some(sequence),
3164                    ok: done.ok,
3165                    params: params_val.clone(),
3166                    result: Some(done.content.clone()),
3167                    via: None,
3168                });
3169                // The child's own receipts, merged and tagged: the parent's
3170                // grounding check ("tests passed" needs a successful `shell`
3171                // receipt) and `receipts.by_tool` must see what the child
3172                // actually ran, or a parent that delegated the test run is
3173                // flagged ungrounded for reporting a result it has evidence for.
3174                let via = format!("{DELEGATE_TOOL}:{id}");
3175                tool_receipts.extend(done.receipts.into_iter().map(|mut r| {
3176                    // Namespace the child's ids under the parent call that
3177                    // produced them. A child loop starts from
3178                    // `prior_assistant_turns = 0`, so its first receipt is
3179                    // `turn_1_call_1` — the same id the parent minted for its
3180                    // own first call. Two rows sharing one id in
3181                    // `tool_receipts` make a host correlating a streamed
3182                    // `tool_result` to a receipt mis-attribute the row.
3183                    r.call_id = Some(match r.call_id {
3184                        Some(child) => format!("{id}/{child}"),
3185                        None => format!("{id}/"),
3186                    });
3187                    r.via = Some(via.clone());
3188                    r
3189                }));
3190                emit(AssistantEvent::ToolResult {
3191                    call_id: id.clone(),
3192                    sequence,
3193                    name: call.name.clone(),
3194                    ok: done.ok,
3195                    content: done.content.clone(),
3196                });
3197                messages.push(Message::ToolResult {
3198                    tool_use_id: provider_id.clone(),
3199                    content: done.content,
3200                    // The child's prose is internal unless one of ITS tool
3201                    // results crossed the boundary — then the parent inherits
3202                    // the marking rather than laundering it through a summary.
3203                    provenance: if done.external {
3204                        Provenance::External
3205                    } else {
3206                        Provenance::Internal
3207                    },
3208                });
3209                if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3210                    if let Err(e) = store
3211                        .checkpoint(session_id, messages, "tool_result", None)
3212                        .await
3213                    {
3214                        let msg = format!("durable checkpoint failed after delegate result: {e}");
3215                        emit(AssistantEvent::Error(msg.clone()));
3216                        return AssistantOutcome {
3217                            status: "error",
3218                            summary: msg,
3219                            turns,
3220                            turns_completed,
3221                            tools_called,
3222                            tool_receipts,
3223                            prior_receipts,
3224                            models_served: models_served.clone(),
3225                            model_used: last_model,
3226                            auth_required: None,
3227                            failure_cause: None,
3228                        };
3229                    }
3230                }
3231                continue;
3232            }
3233
3234            let proposal = match build_proposal(&result.model_used, call, &id, &params_val) {
3235                Ok(p) => p,
3236                Err(e) => {
3237                    // A malformed call shape shouldn't sink the run; feed the
3238                    // error back so the model can retry with a valid shape.
3239                    let content = cap(json!({ "error": e }).to_string());
3240                    tool_receipts.push(AssistantToolReceipt {
3241                        tool: call.name.clone(),
3242                        call_id: Some(id.clone()),
3243                        sequence: Some(sequence),
3244                        ok: false,
3245                        params: params_val.clone(),
3246                        result: Some(content.clone()),
3247                        via: None,
3248                    });
3249                    emit(AssistantEvent::ToolResult {
3250                        call_id: id.clone(),
3251                        sequence,
3252                        name: call.name.clone(),
3253                        ok: false,
3254                        content: content.clone(),
3255                    });
3256                    messages.push(Message::ToolResult {
3257                        tool_use_id: provider_id.clone(),
3258                        content,
3259                        // A shape error the runtime itself wrote.
3260                        provenance: Provenance::Internal,
3261                    });
3262                    if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3263                        if let Err(e) = store
3264                            .checkpoint(session_id, messages, "malformed_tool_call", None)
3265                            .await
3266                        {
3267                            let msg = format!("durable checkpoint failed after tool error: {e}");
3268                            emit(AssistantEvent::Error(msg.clone()));
3269                            return AssistantOutcome {
3270                                status: "error",
3271                                summary: msg,
3272                                turns,
3273                                turns_completed,
3274                                tools_called,
3275                                tool_receipts,
3276                                prior_receipts,
3277                                models_served: models_served.clone(),
3278                                model_used: last_model,
3279                                auth_required: None,
3280                                failure_cause: None,
3281                            };
3282                        }
3283                    }
3284                    continue;
3285                }
3286            };
3287
3288            if needs_approval {
3289                let dispatch = match approval {
3290                    Some(gate) => gate.before_dispatch(&id, &call.name, &params_val).await,
3291                    None => Err("approval gate disappeared before dispatch".into()),
3292                };
3293                if let Err(e) = dispatch {
3294                    let content =
3295                        cap(json!({ "error": format!("dispatch refused: {e}") }).to_string());
3296                    tool_receipts.push(AssistantToolReceipt {
3297                        tool: call.name.clone(),
3298                        call_id: Some(id.clone()),
3299                        sequence: Some(sequence),
3300                        ok: false,
3301                        params: params_val.clone(),
3302                        result: Some(content.clone()),
3303                        via: None,
3304                    });
3305                    emit(AssistantEvent::ToolResult {
3306                        call_id: id.clone(),
3307                        sequence,
3308                        name: call.name.clone(),
3309                        ok: false,
3310                        content: content.clone(),
3311                    });
3312                    messages.push(Message::ToolResult {
3313                        tool_use_id: provider_id.clone(),
3314                        content,
3315                        provenance: Provenance::Internal,
3316                    });
3317                    if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3318                        let _ = store
3319                            .checkpoint(session_id, messages, "dispatch_refused", None)
3320                            .await;
3321                    }
3322                    continue;
3323                }
3324            }
3325
3326            let exec = match runtime_session_id {
3327                Some(session_id) => runtime.execute_with_session(&proposal, session_id).await,
3328                None => runtime.execute(&proposal).await,
3329            };
3330            let action = exec.results.first();
3331            let runtime_succeeded = action
3332                .map(|r| matches!(r.status, ActionStatus::Succeeded))
3333                .unwrap_or(false);
3334            // Shell non-zero is intentionally returned as an observation so
3335            // the model can repair it, but it is not a successful receipt.
3336            // Treating Runtime::Succeeded as "tests passed" was a false-proof
3337            // bug because the executor successfully *ran* a command that
3338            // exited 1.
3339            let ok = runtime_succeeded
3340                && (call.name != "shell"
3341                    || action
3342                        .and_then(|result| result.output.as_ref())
3343                        .and_then(|output| output.get("exit_code"))
3344                        .and_then(Value::as_i64)
3345                        == Some(0));
3346            if needs_approval {
3347                let receipt = json!({
3348                    "ok": ok,
3349                    "action_id": action.map(|result| result.action_id.clone()),
3350                    "status": action.map(|result| format!("{:?}", result.status)),
3351                });
3352                if let Some(gate) = approval {
3353                    if let Err(e) = gate
3354                        .after_dispatch(&id, &call.name, &params_val, ok, &receipt)
3355                        .await
3356                    {
3357                        let msg = format!(
3358                            "action executed but its durable terminal receipt failed: {e}; action is indeterminate"
3359                        );
3360                        let raw_content = json!({ "error": msg }).to_string();
3361                        tool_receipts.push(AssistantToolReceipt {
3362                            tool: call.name.clone(),
3363                            call_id: Some(id.clone()),
3364                            sequence: Some(sequence),
3365                            ok: false,
3366                            params: params_val.clone(),
3367                            result: Some(raw_content.clone()),
3368                            via: None,
3369                        });
3370                        emit(AssistantEvent::ToolResult {
3371                            call_id: id.clone(),
3372                            sequence,
3373                            name: call.name.clone(),
3374                            ok: false,
3375                            content: raw_content,
3376                        });
3377                        emit(AssistantEvent::Error(msg.clone()));
3378                        return AssistantOutcome {
3379                            status: "error",
3380                            summary: msg,
3381                            turns,
3382                            turns_completed,
3383                            tools_called,
3384                            tool_receipts,
3385                            prior_receipts,
3386                            models_served: models_served.clone(),
3387                            model_used: last_model,
3388                            auth_required: None,
3389                            failure_cause: None,
3390                        };
3391                    }
3392                }
3393            }
3394            // Observation shaping (#813). Only a result that WOULD have been
3395            // destructively truncated is replaced by a preview: one that
3396            // already fits is strictly more useful shown whole, and paying a
3397            // handle + indirection for it would trade information the model
3398            // had for free against nothing.
3399            //
3400            // Note this is the narrower of the two possible readings of #813.
3401            // Previewing EVERY result — the shape NVIDIA's numbers come from —
3402            // would also cut the per-turn re-serialization cost, but it removes
3403            // detail from results that fit today. That is exactly the kind of
3404            // trade the `car-bench` A/B exists to settle, so it is deliberately
3405            // not assumed here.
3406            // Keep the complete observation for the host receipt before the
3407            // model-facing cap can turn a large JSON value into invalid JSON.
3408            // Wire projection redacts and applies its own smaller bounds.
3409            let raw_content = action
3410                .map(format_tool_result)
3411                .unwrap_or_else(|| format!("tool '{}' produced no result", call.name));
3412            let content = match action {
3413                Some(r)
3414                    if cfg.value_store_previews && matches!(r.status, ActionStatus::Succeeded) =>
3415                {
3416                    match (&r.output, raw_content.len() > OBSERVATION_CAP) {
3417                        (Some(v), true) => {
3418                            let handle = values.put(v.clone());
3419                            format!(
3420                                "{}{}",
3421                                super::value_store::render_preview(&handle, v),
3422                                super::value_store::reference_hint(&handle)
3423                            )
3424                        }
3425                        // Fits, or carries no structured output to retain —
3426                        // a handle to nothing helps nobody.
3427                        _ => cap(raw_content.clone()),
3428                    }
3429                }
3430                _ => cap(raw_content.clone()),
3431            };
3432            if ok {
3433                tools_called.push(call.name.clone());
3434                if mutating_tools.contains(&call.name) {
3435                    mutated_ok = true;
3436                }
3437            }
3438            tool_receipts.push(AssistantToolReceipt {
3439                tool: call.name.clone(),
3440                call_id: Some(id.clone()),
3441                sequence: Some(sequence),
3442                ok,
3443                params: params_val.clone(),
3444                result: Some(raw_content.clone()),
3445                via: None,
3446            });
3447            // Learn from what just happened: a failure opens a signature, a
3448            // success on the same tool inside the recovery window closes it and
3449            // captures the call that worked. `content` is the rendered
3450            // observation because it is where BOTH failure shapes are already
3451            // normalized — a runtime `[FAILED] …` and a `shell` that ran fine
3452            // but exited non-zero.
3453            record_tool_outcome(
3454                cfg,
3455                &mut open_failures,
3456                &call.name,
3457                ok,
3458                &content,
3459                &params_val,
3460                turns,
3461            );
3462            emit(AssistantEvent::ToolResult {
3463                call_id: id.clone(),
3464                sequence,
3465                name: call.name.clone(),
3466                ok,
3467                content: raw_content,
3468            });
3469            messages.push(Message::ToolResult {
3470                tool_use_id: provider_id,
3471                content,
3472                // The only site that can carry bytes from outside the trust
3473                // boundary. Classified from the tool's information-flow labels
3474                // rather than a name list local to this file — see
3475                // `tool_output_is_external`.
3476                provenance: if tool_output_is_external(&call.name, tool_labels) {
3477                    Provenance::External
3478                } else {
3479                    Provenance::Internal
3480                },
3481            });
3482            if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3483                if let Err(e) = store
3484                    .checkpoint(session_id, messages, "tool_result", None)
3485                    .await
3486                {
3487                    let msg = format!("durable checkpoint failed after tool result: {e}");
3488                    emit(AssistantEvent::Error(msg.clone()));
3489                    return AssistantOutcome {
3490                        status: "error",
3491                        summary: msg,
3492                        turns,
3493                        turns_completed,
3494                        tools_called,
3495                        tool_receipts,
3496                        prior_receipts,
3497                        models_served: models_served.clone(),
3498                        model_used: last_model,
3499                        auth_required: None,
3500                        failure_cause: None,
3501                    };
3502                }
3503            }
3504        }
3505
3506        // No-progress guard, now that we know what actually SUCCEEDED. A real,
3507        // NEW state mutation resets everything (progress). A repeated signature —
3508        // even to a mutating tool — is a tight loop (nudge at STALL_NUDGE, stop at
3509        // STALL_BREAK); too many turns gathering info / failing to change anything
3510        // earns one soft nudge to act (EXPLORE_NUDGE) — no hard stop, since a
3511        // genuinely read-only task legitimately never mutates.
3512        let mut inject_nudge = false;
3513        match guard.observe(&tool_calls_signature(&calls), mutated_ok) {
3514            GuardStep::Break => {
3515                let summary = format!(
3516                    "Stopped: repeated the same action {} times without changing \
3517                     anything — no progress was being made.",
3518                    guard.stall_repeats
3519                );
3520                runtime
3521                    .record_turn_completed("stalled", None, false, turns, &last_model)
3522                    .await;
3523                emit(AssistantEvent::Done {
3524                    text: summary.clone(),
3525                });
3526                return AssistantOutcome {
3527                    status: "stalled",
3528                    summary,
3529                    turns,
3530                    turns_completed,
3531                    tools_called,
3532                    tool_receipts,
3533                    prior_receipts,
3534                    models_served: models_served.clone(),
3535                    model_used: last_model.clone(),
3536                    auth_required: None,
3537                    failure_cause: None,
3538                };
3539            }
3540            GuardStep::Nudge => inject_nudge = true,
3541            GuardStep::Progress | GuardStep::Continue => {}
3542        }
3543
3544        // The model has been repeating itself: prod it to act or finish. Injected
3545        // after the tool results so it reads as guidance on the just-seen output.
3546        if inject_nudge {
3547            messages.push(Message::User {
3548                content: "You have repeated the same action several times without \
3549                          changing anything or making progress. Stop re-reading and \
3550                          either take a concrete action (write or edit a file, run a \
3551                          command) or, if the task is genuinely complete, finish now \
3552                          with your summary."
3553                    .into(),
3554            });
3555            if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
3556                if let Err(e) = store
3557                    .checkpoint(session_id, messages, "progress_nudge", None)
3558                    .await
3559                {
3560                    let msg = format!("durable checkpoint failed after progress nudge: {e}");
3561                    emit(AssistantEvent::Error(msg.clone()));
3562                    return AssistantOutcome {
3563                        status: "error",
3564                        summary: msg,
3565                        turns,
3566                        turns_completed,
3567                        tools_called,
3568                        tool_receipts,
3569                        prior_receipts,
3570                        models_served: models_served.clone(),
3571                        model_used: last_model,
3572                        auth_required: None,
3573                        failure_cause: None,
3574                    };
3575                }
3576            }
3577        }
3578    }
3579
3580    runtime
3581        .record_turn_completed("max_turns", None, false, turns, &last_model)
3582        .await;
3583    AssistantOutcome {
3584        status: "max_turns",
3585        summary: if last_text.is_empty() {
3586            format!("stopped after {} turns without finishing", cfg.max_turns)
3587        } else {
3588            last_text
3589        },
3590        turns,
3591        turns_completed,
3592        tools_called,
3593        tool_receipts,
3594        prior_receipts,
3595        models_served: models_served.clone(),
3596        model_used: last_model.clone(),
3597        auth_required: None,
3598        failure_cause: None,
3599    }
3600}
3601
3602#[derive(Debug, Clone)]
3603struct SummaryClaimRequirement {
3604    label: &'static str,
3605    tools: &'static [&'static str],
3606    require_ok: bool,
3607    shell_terms: &'static [&'static str],
3608    paths: Vec<String>,
3609}
3610
3611const TEST_TERMS: &[&str] = &[
3612    "test",
3613    "pytest",
3614    "cargo test",
3615    "cargo nextest",
3616    "npm test",
3617    "npm run test",
3618    "pnpm test",
3619    "pnpm run test",
3620    "yarn test",
3621    "bun test",
3622    "go test",
3623    "swift test",
3624    "dotnet test",
3625    "ctest",
3626    "cmake --build",
3627    "make test",
3628];
3629const BUILD_TERMS: &[&str] = &[
3630    "build",
3631    "cargo check",
3632    "cargo build",
3633    "npm run build",
3634    "pnpm build",
3635    "yarn build",
3636    "bun run build",
3637    "cmake --build",
3638    "go build",
3639    "swift build",
3640    "dotnet build",
3641    "mvn package",
3642    "gradle build",
3643    "./gradlew build",
3644];
3645const CHECK_TERMS: &[&str] = &[
3646    "cargo check",
3647    "git diff --check",
3648    "npm run lint",
3649    "npm run check",
3650    "pnpm check",
3651    "pnpm lint",
3652    "yarn check",
3653    "yarn lint",
3654    "bun run check",
3655    "eslint",
3656    "clippy",
3657    "swiftlint",
3658    "ruff",
3659    "mypy",
3660    "biome check",
3661];
3662// Shell-command substrings that evidence a read / write, used to decide whether a
3663// final-summary claim ("I read the files", "I created the file") is backed by a
3664// real tool receipt. The assistant's shell is `cmd /C` on Windows, so these must
3665// carry the cmd spellings too — otherwise a Windows run that genuinely wrote a
3666// file produces no recognized receipt and the claim check false-negatives against
3667// the model (annotating a truthful summary as unverified, or failing a
3668// judge-dependent verdict closed).
3669// Matching is a plain `cmd.contains(term)` (see `receipt_supports_claim`), so a
3670// term must not be a substring of an unrelated command: `"dir "` is deliberately
3671// absent because it also matches `mkdir `, which would let a *write* stand in as
3672// a *read* receipt on every platform.
3673const READ_TERMS: &[&str] = &[
3674    "cat ", "sed ", "rg ", "grep ", "ls ", "find ", // POSIX
3675    "type ", "findstr ", // cmd
3676];
3677const WRITE_TERMS: &[&str] = &[
3678    "touch ",
3679    "cat >",
3680    "tee ",
3681    "python ",
3682    "node ",
3683    "perl ", // POSIX
3684    "type nul >",
3685    "echo >", // cmd
3686];
3687const GIT_STATUS_TERMS: &[&str] = &["git status"];
3688const GIT_REVISION_TERMS: &[&str] = &["git rev-parse", "git log", "git show"];
3689const APP_INSIGHTS_TERMS: &[&str] = &["az monitor app-insights query"];
3690const DEPLOYMENT_EVIDENCE_TERMS: &[&str] = &[
3691    "az pipelines show",
3692    "az pipelines runs show",
3693    "az devops invoke",
3694];
3695const SUMMARY_PATH_EXTENSIONS: &[&str] = &[
3696    ".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".swift", ".java", ".kt", ".kts", ".c",
3697    ".h", ".cc", ".hh", ".cpp", ".hpp", ".cxx", ".hxx", ".cs", ".fs", ".vb", ".php", ".rb", ".ex",
3698    ".exs", ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".html", ".css", ".xml", ".sh",
3699    ".sql",
3700];
3701
3702fn normalize_summary_path_token(raw: &str) -> Option<String> {
3703    let token = raw.trim_matches(|c: char| {
3704        matches!(
3705            c,
3706            '"' | '\'' | '`' | ',' | ';' | ':' | ')' | '(' | '[' | ']' | '{' | '}' | '.'
3707        )
3708    });
3709    if token.is_empty() || token.starts_with('-') || token.contains("://") || token.contains("..") {
3710        return None;
3711    }
3712    let looks_like_path = token.contains('/')
3713        || SUMMARY_PATH_EXTENSIONS
3714            .iter()
3715            .any(|ext| token.to_ascii_lowercase().ends_with(ext));
3716    if !looks_like_path {
3717        return None;
3718    }
3719    Some(
3720        token
3721            .trim_start_matches("./")
3722            .replace('\\', "/")
3723            .to_ascii_lowercase(),
3724    )
3725}
3726
3727fn summary_path_hints(summary: &str) -> Vec<String> {
3728    let mut paths = Vec::new();
3729    for raw in summary.split_whitespace() {
3730        if let Some(path) = normalize_summary_path_token(raw) {
3731            if !paths.contains(&path) {
3732                paths.push(path);
3733            }
3734        }
3735    }
3736    paths
3737}
3738
3739fn summary_claim_requirements(summary: &str) -> Vec<SummaryClaimRequirement> {
3740    let s = summary.to_ascii_lowercase();
3741    let units: Vec<&str> = s
3742        .split(['\n', '.'])
3743        .map(str::trim)
3744        .filter(|unit| !unit.is_empty())
3745        .collect();
3746    let path_hints = summary_path_hints(summary);
3747    let mut claims = Vec::new();
3748    if units.iter().any(|unit| {
3749        unit.contains("ran the test")
3750            || unit.contains("ran tests")
3751            || (unit.contains("verified with") && unit.contains("test"))
3752            || (unit.contains("test")
3753                && (unit.contains("passed")
3754                    || unit.contains("green")
3755                    || unit.contains("succeeded")
3756                    || unit.contains("successful")))
3757    }) {
3758        claims.push(SummaryClaimRequirement {
3759            label: "tests were run/passed",
3760            tools: &["shell"],
3761            require_ok: true,
3762            shell_terms: TEST_TERMS,
3763            paths: Vec::new(),
3764        });
3765    }
3766    if units.iter().any(|unit| {
3767        (unit.contains("build") || unit.contains("cargo check"))
3768            && (unit.contains("passed")
3769                || unit.contains("succeeded")
3770                || unit.contains("successful")
3771                || unit.contains("built")
3772                || unit.contains("green")
3773                || unit.contains("ran the build")
3774                || unit.contains("ran cargo check"))
3775    }) {
3776        claims.push(SummaryClaimRequirement {
3777            label: "build succeeded",
3778            tools: &["shell"],
3779            require_ok: true,
3780            shell_terms: BUILD_TERMS,
3781            paths: Vec::new(),
3782        });
3783    }
3784    if units.iter().any(|unit| {
3785        (unit.contains("check") || unit.contains("lint"))
3786            && (unit.contains("passed")
3787                || unit.contains("green")
3788                || unit.contains("succeeded")
3789                || unit.contains("successful"))
3790    }) {
3791        claims.push(SummaryClaimRequirement {
3792            label: "checks were run/passed",
3793            tools: &["shell"],
3794            require_ok: true,
3795            shell_terms: CHECK_TERMS,
3796            paths: Vec::new(),
3797        });
3798    }
3799    if units.iter().any(|unit| {
3800        (unit.contains("read ") || unit.contains("inspected ") || unit.contains("looked at "))
3801            && (unit.contains("file") || unit.contains("source"))
3802    }) {
3803        claims.push(SummaryClaimRequirement {
3804            label: "files were read/inspected",
3805            tools: &["read_file", "list_dir", "find_files", "grep_files", "shell"],
3806            require_ok: true,
3807            shell_terms: READ_TERMS,
3808            paths: path_hints.clone(),
3809        });
3810    }
3811    if units.iter().any(|unit| {
3812        (unit.contains("created")
3813            || unit.contains("wrote")
3814            || unit.contains("updated")
3815            || unit.contains("edited"))
3816            && unit.contains("file")
3817    }) {
3818        claims.push(SummaryClaimRequirement {
3819            label: "files were created/updated",
3820            tools: &["write_file", "edit_file", "shell"],
3821            require_ok: true,
3822            shell_terms: WRITE_TERMS,
3823            paths: path_hints.clone(),
3824        });
3825    }
3826    if units.iter().any(|unit| {
3827        unit.contains("repository is clean")
3828            || unit.contains("repo is clean")
3829            || unit.contains("working tree is clean")
3830            || unit.contains("status: clean")
3831    }) {
3832        claims.push(SummaryClaimRequirement {
3833            label: "repository cleanliness was verified",
3834            tools: &["shell"],
3835            require_ok: true,
3836            shell_terms: GIT_STATUS_TERMS,
3837            paths: Vec::new(),
3838        });
3839    }
3840    if units.iter().any(|unit| {
3841        unit.contains("head matches origin")
3842            || unit.contains("head is aligned with origin")
3843            || unit.contains("head and origin are identical")
3844    }) {
3845        claims.push(SummaryClaimRequirement {
3846            label: "repository revision/remote relationship was verified",
3847            tools: &["shell"],
3848            require_ok: true,
3849            shell_terms: GIT_REVISION_TERMS,
3850            paths: Vec::new(),
3851        });
3852    }
3853    if units.iter().any(|unit| {
3854        (unit.contains("app insights")
3855            || unit.contains("application insights")
3856            || unit.contains("telemetry"))
3857            && (unit.contains("query showed")
3858                || unit.contains("query confirmed")
3859                || unit.contains("we observed")
3860                || unit.contains("live telemetry showed")
3861                || unit.contains("no recurrence")
3862                || unit.contains("recurred after"))
3863            && !unit.contains("not obtained")
3864            && !unit.contains("unable")
3865    }) {
3866        claims.push(SummaryClaimRequirement {
3867            label: "live Application Insights evidence was observed",
3868            tools: &["shell", "browse_observe"],
3869            require_ok: true,
3870            shell_terms: APP_INSIGHTS_TERMS,
3871            paths: Vec::new(),
3872        });
3873    }
3874    if units.iter().any(|unit| {
3875        (unit.contains("production") || unit.contains("live"))
3876            && (unit.contains("browser") || unit.contains("portal") || unit.contains("page"))
3877            && (unit.contains("inspected")
3878                || unit.contains("observed")
3879                || unit.contains("verified"))
3880            && !unit.contains("not obtained")
3881            && !unit.contains("unable")
3882    }) {
3883        claims.push(SummaryClaimRequirement {
3884            label: "production browser state was observed",
3885            tools: &["browse_observe"],
3886            require_ok: true,
3887            shell_terms: &[],
3888            paths: Vec::new(),
3889        });
3890    }
3891    if units.iter().any(|unit| {
3892        unit.contains("deployment")
3893            && (unit.contains("successfully fixed")
3894                || unit.contains("was deployed")
3895                || unit.contains("after fix")
3896                || unit.contains("post-deployment"))
3897            && !unit.contains("cannot")
3898            && !unit.contains("not obtained")
3899    }) {
3900        claims.push(SummaryClaimRequirement {
3901            label: "deployment state/change was verified",
3902            tools: &["shell"],
3903            require_ok: true,
3904            shell_terms: DEPLOYMENT_EVIDENCE_TERMS,
3905            paths: Vec::new(),
3906        });
3907    }
3908    if units.iter().any(|unit| {
3909        unit.contains("subscription")
3910            && (unit.contains("outside") || unit.contains("not in"))
3911            && unit
3912                .as_bytes()
3913                .windows(2)
3914                .any(|window| window[0] == b'n' && window[1].is_ascii_digit())
3915            && !unit.contains("cannot")
3916            && !unit.contains("not verified")
3917            && !unit.contains("not obtained")
3918            && !unit.contains("insufficient evidence")
3919    }) {
3920        claims.push(SummaryClaimRequirement {
3921            label: "named aircraft subscription status was observed live",
3922            tools: &["shell"],
3923            require_ok: true,
3924            shell_terms: APP_INSIGHTS_TERMS,
3925            paths: Vec::new(),
3926        });
3927    }
3928    claims
3929}
3930
3931fn shell_command(params: &Value) -> Option<String> {
3932    params
3933        .get("command")
3934        .and_then(Value::as_str)
3935        .map(|s| s.to_ascii_lowercase())
3936}
3937
3938fn normalized_receipt_path(params: &Value) -> Option<String> {
3939    params.get("path").and_then(Value::as_str).map(|path| {
3940        path.trim_start_matches("./")
3941            .replace('\\', "/")
3942            .to_ascii_lowercase()
3943    })
3944}
3945
3946fn text_mentions_summary_path(text: &str, path: &str) -> bool {
3947    let text = text.replace('\\', "/").to_ascii_lowercase();
3948    text.contains(path) || text.contains(&format!("./{path}"))
3949}
3950
3951fn receipt_mentions_summary_path(receipt: &AssistantToolReceipt, path: &str) -> bool {
3952    if receipt.tool == "shell" {
3953        return shell_command(&receipt.params)
3954            .map(|cmd| text_mentions_summary_path(&cmd, path))
3955            .unwrap_or(false);
3956    }
3957    normalized_receipt_path(&receipt.params)
3958        .map(|receipt_path| text_mentions_summary_path(&receipt_path, path))
3959        .unwrap_or(false)
3960}
3961
3962fn receipt_satisfies_claim(
3963    receipt: &AssistantToolReceipt,
3964    claim: &SummaryClaimRequirement,
3965) -> bool {
3966    if claim.require_ok && !receipt.ok {
3967        return false;
3968    }
3969    if !claim.tools.iter().any(|t| *t == receipt.tool) {
3970        return false;
3971    }
3972    if !claim.paths.is_empty()
3973        && !claim
3974            .paths
3975            .iter()
3976            .any(|path| receipt_mentions_summary_path(receipt, path))
3977    {
3978        return false;
3979    }
3980    if receipt.tool != "shell" || claim.shell_terms.is_empty() {
3981        return true;
3982    }
3983    let Some(cmd) = shell_command(&receipt.params) else {
3984        return false;
3985    };
3986    claim.shell_terms.iter().any(|term| cmd.contains(term))
3987}
3988
3989/// Operational claims the final prose makes that no same-run tool receipt
3990/// supports — "I ran the tests" with no matching shell call, "I created X"
3991/// with no matching write.
3992///
3993/// Public because a machine-readable caller (`car do --json`, and any host
3994/// embedding the assistant) needs this as a **field**, not as prose appended
3995/// to the summary. Folded into the text it is a note a relaying model can
3996/// silently drop; as data it is a caution the caller must decide what to do
3997/// with. This is the mechanical half of "receipts decide completion".
3998///
3999/// Detection is lexical and deliberately conservative: it flags claims whose
4000/// wording names an operation, and stays silent otherwise. An empty result
4001/// means "nothing detected", NOT "the summary is verified".
4002pub fn ungrounded_summary_claims(
4003    summary: &str,
4004    receipts: &[AssistantToolReceipt],
4005) -> Vec<&'static str> {
4006    summary_claim_requirements(summary)
4007        .into_iter()
4008        .filter(|claim| {
4009            !receipts
4010                .iter()
4011                .any(|receipt| receipt_satisfies_claim(receipt, claim))
4012        })
4013        .map(|claim| claim.label)
4014        .collect()
4015}
4016
4017fn apply_summary_claim_grounding(
4018    mut verdict: car_verify::goal::GoalVerdict,
4019    outcome: &AssistantOutcome,
4020) -> car_verify::goal::GoalVerdict {
4021    if !verdict.met {
4022        return verdict;
4023    }
4024    let ungrounded = ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
4025    if ungrounded.is_empty() {
4026        return verdict;
4027    }
4028    verdict.grounded = false;
4029    verdict.reason = format!(
4030        "{}; ungrounded assistant summary claim(s): {}",
4031        verdict.reason,
4032        ungrounded.join(", ")
4033    );
4034    verdict
4035}
4036
4037/// Append a **non-authoritative** claim-check note to a goal-loop reply.
4038///
4039/// Used only on the deterministic-pass path (F9): when the goal check already
4040/// passed against ground truth but the final prose named an operational claim
4041/// ("tests passed", "created file X") with no matching same-run tool receipt,
4042/// the completion still stands — this only flags the unverified wording to the
4043/// reader. Written ONLY to the returned `GoalLoopResult.outcome.summary`; the
4044/// caller must never fold it into the `messages` Vec, which chat.rs persists
4045/// into the session thread (the note would then leak into later turns' context).
4046pub fn annotate_summary_with_claim_note(summary: &str, ungrounded: &[&'static str]) -> String {
4047    if ungrounded.is_empty() {
4048        return summary.to_string();
4049    }
4050    format!(
4051        "{summary}\n\n[claim check] unverified summary claim(s) this run \
4052         (no matching tool receipt): {}",
4053        ungrounded.join(", ")
4054    )
4055}
4056
4057/// The result of a goal-driven assistant run: the last iteration's
4058/// [`AssistantOutcome`] plus the `GoalRun` audit (per-iteration verdicts,
4059/// grounded flag, halt reason).
4060pub struct GoalLoopResult {
4061    pub outcome: AssistantOutcome,
4062    pub run: car_verify::goal::GoalRun,
4063}
4064
4065/// Upper bound on one `gather` pass — the "is this iteration's condition met?"
4066/// evaluation run after every iteration (car#1112). `gather` is caller-supplied
4067/// and may itself await a human approval, a subprocess, or (for a future
4068/// [`car_verify::goal::GoalCondition::ModelJudge`]) an inference call; none of
4069/// those callees is guaranteed to resolve on their own. Reuses the crate's
4070/// existing single-check ceiling (`car do`'s shell tool) rather than inventing
4071/// a second magic number for what is, structurally, the same kind of wait.
4072const GOAL_EVALUATION_TIMEOUT: Duration =
4073    Duration::from_secs(crate::coder::shell_tool::DEFAULT_SHELL_TIMEOUT_SECS);
4074
4075/// Drive the assistant as a **goal loop**: keep running iterations (each a full
4076/// [`run_assistant_loop_cancellable`] pass — the model works until it stops
4077/// emitting tool calls) until a **deterministic** [`car_verify::goal::GoalCondition`]
4078/// holds or a [`car_verify::goal::GoalGovernor`] bound is hit. This is CAR's
4079/// answer to `/goal`: the "am I done?" decision is made by
4080/// [`car_verify::goal::evaluate_goal`] over ground truth gathered from the
4081/// runtime (`gather`), never by a model reading its own transcript.
4082///
4083/// `messages` should carry only the system turn; the loop drives every user turn
4084/// from the pinned goal (the drift anchor, re-derived each iteration via
4085/// [`car_verify::goal::anchor_directive`], so no turn can repoint the objective).
4086/// `gather(&outcome)` projects a `GoalGather` after each iteration — the caller
4087/// owns which command/model checks to run; the runtime folds in receipts/state.
4088pub async fn run_assistant_goal_loop<G, GF>(
4089    generator: &dyn TurnGenerator,
4090    runtime: &Runtime,
4091    cfg: &AssistantConfig,
4092    messages: &mut Vec<Message>,
4093    cancel: &std::sync::atomic::AtomicBool,
4094    approval: Option<&dyn ApprovalGate>,
4095    spec: &car_verify::goal::GoalSpec,
4096    gather: G,
4097    emit: impl FnMut(AssistantEvent),
4098) -> GoalLoopResult
4099where
4100    G: FnMut(&AssistantOutcome) -> GF,
4101    GF: std::future::Future<Output = car_engine::GoalGather>,
4102{
4103    run_assistant_goal_loop_in_session(
4104        generator, runtime, cfg, messages, cancel, approval, spec, None, gather, emit,
4105    )
4106    .await
4107}
4108
4109/// Session-aware variant of [`run_assistant_goal_loop`].
4110pub async fn run_assistant_goal_loop_in_session<G, GF>(
4111    generator: &dyn TurnGenerator,
4112    runtime: &Runtime,
4113    cfg: &AssistantConfig,
4114    messages: &mut Vec<Message>,
4115    cancel: &std::sync::atomic::AtomicBool,
4116    approval: Option<&dyn ApprovalGate>,
4117    spec: &car_verify::goal::GoalSpec,
4118    runtime_session_id: Option<&str>,
4119    gather: G,
4120    emit: impl FnMut(AssistantEvent),
4121) -> GoalLoopResult
4122where
4123    G: FnMut(&AssistantOutcome) -> GF,
4124    GF: std::future::Future<Output = car_engine::GoalGather>,
4125{
4126    run_assistant_goal_loop_in_session_durable(
4127        generator,
4128        runtime,
4129        cfg,
4130        messages,
4131        cancel,
4132        approval,
4133        spec,
4134        runtime_session_id,
4135        None,
4136        None,
4137        gather,
4138        emit,
4139    )
4140    .await
4141}
4142
4143pub async fn run_assistant_goal_loop_in_session_durable<G, GF>(
4144    generator: &dyn TurnGenerator,
4145    runtime: &Runtime,
4146    cfg: &AssistantConfig,
4147    messages: &mut Vec<Message>,
4148    cancel: &std::sync::atomic::AtomicBool,
4149    approval: Option<&dyn ApprovalGate>,
4150    spec: &car_verify::goal::GoalSpec,
4151    runtime_session_id: Option<&str>,
4152    durable_session_id: Option<&str>,
4153    durability: Option<&dyn super::governance::AssistantDurability>,
4154    mut gather: G,
4155    mut emit: impl FnMut(AssistantEvent),
4156) -> GoalLoopResult
4157where
4158    G: FnMut(&AssistantOutcome) -> GF,
4159    GF: std::future::Future<Output = car_engine::GoalGather>,
4160{
4161    use car_verify::goal::{
4162        anchor_directive, evaluate_goal, governor_check, GoalHalt, GoalRun, GoalRunState,
4163        GoalStatus, GoalVerdict,
4164    };
4165    use std::sync::atomic::Ordering;
4166
4167    let start = std::time::Instant::now();
4168    let mut run_state = GoalRunState::default();
4169    let mut evidence: Vec<GoalVerdict> = Vec::new();
4170    let mut all_models_served = Vec::new();
4171    let mut last_reason = String::new();
4172    let mut last_outcome = AssistantOutcome {
4173        status: "goal_pending",
4174        summary: String::new(),
4175        turns: 0,
4176        turns_completed: 0,
4177        tools_called: Vec::new(),
4178        tool_receipts: Vec::new(),
4179        prior_receipts: 0,
4180        models_served: Vec::new(),
4181        model_used: String::new(),
4182        auth_required: None,
4183        failure_cause: None,
4184    };
4185
4186    let finish = |status: GoalStatus,
4187                  grounded: bool,
4188                  reason: String,
4189                  iterations: u32,
4190                  evidence: Vec<GoalVerdict>,
4191                  outcome: AssistantOutcome|
4192     -> GoalLoopResult {
4193        GoalLoopResult {
4194            run: GoalRun {
4195                status,
4196                iterations,
4197                grounded,
4198                cost_usd: 0.0,
4199                last_reason: reason,
4200                evidence,
4201            },
4202            outcome,
4203        }
4204    };
4205
4206    loop {
4207        run_state.elapsed_secs = start.elapsed().as_secs();
4208        if cancel.load(Ordering::Relaxed) {
4209            return finish(
4210                GoalStatus::Halted {
4211                    halt: GoalHalt::Cancelled,
4212                },
4213                evidence.last().map(|v| v.grounded).unwrap_or(true),
4214                "cancelled".into(),
4215                run_state.turns,
4216                evidence,
4217                last_outcome,
4218            );
4219        }
4220        if let Some(halt) = governor_check(&spec.governor, &run_state) {
4221            return finish(
4222                GoalStatus::Halted { halt },
4223                evidence.last().map(|v| v.grounded).unwrap_or(true),
4224                if last_reason.is_empty() {
4225                    halt.as_str().to_string()
4226                } else {
4227                    format!("{} ({})", halt.as_str(), last_reason)
4228                },
4229                run_state.turns,
4230                evidence,
4231                last_outcome,
4232            );
4233        }
4234
4235        // Anchor the directive from the pinned goal and push it as the next
4236        // user turn (the loop owns all user turns).
4237        let directive = anchor_directive(&spec.goal, &last_reason);
4238        messages.push(Message::User { content: directive });
4239        if let (Some(store), Some(session_id)) = (durability, durable_session_id) {
4240            if let Err(e) = store
4241                .checkpoint(
4242                    session_id,
4243                    messages,
4244                    "goal_directive",
4245                    serde_json::to_value(spec).ok(),
4246                )
4247                .await
4248            {
4249                last_outcome.status = "error";
4250                last_outcome.summary = format!("durable goal checkpoint failed: {e}");
4251                return finish(
4252                    GoalStatus::Halted {
4253                        halt: GoalHalt::Cancelled,
4254                    },
4255                    false,
4256                    last_outcome.summary.clone(),
4257                    run_state.turns,
4258                    evidence,
4259                    last_outcome,
4260                );
4261            }
4262        }
4263
4264        let mut outcome = run_assistant_loop_cancellable_in_session_durable(
4265            generator,
4266            runtime,
4267            cfg,
4268            messages,
4269            cancel,
4270            approval,
4271            None,
4272            runtime_session_id,
4273            durable_session_id,
4274            durability,
4275            false,
4276            &mut emit,
4277        )
4278        .await;
4279        all_models_served.append(&mut outcome.models_served);
4280        outcome.models_served = all_models_served.clone();
4281        run_state.turns += 1;
4282        // Progress = the iteration actually executed a tool. A prose-only turn
4283        // that didn't move the world is thrash (drives the no-progress guard).
4284        if outcome.tools_called.is_empty() {
4285            run_state.turns_since_progress += 1;
4286        } else {
4287            run_state.turns_since_progress = 0;
4288        }
4289
4290        if outcome.status == "cancelled" {
4291            return finish(
4292                GoalStatus::Halted {
4293                    halt: GoalHalt::Cancelled,
4294                },
4295                evidence.last().map(|v| v.grounded).unwrap_or(true),
4296                "cancelled".into(),
4297                run_state.turns,
4298                evidence,
4299                outcome,
4300            );
4301        }
4302
4303        // The account, not the work. Return BEFORE `gather` — running the
4304        // verifier would grade an iteration that never happened, emit a
4305        // `goal_evaluated` saying "not met" about a turn nobody ran, and then
4306        // re-drive the model into the same wall for the rest of the iteration
4307        // budget. The caller is owed the remedy, once.
4308        if outcome.status == "auth_required" {
4309            let reason = outcome.summary.clone();
4310            return finish(
4311                GoalStatus::Halted {
4312                    halt: GoalHalt::AuthRequired,
4313                },
4314                // Not grounded: no deterministic check ran, so there is no
4315                // verdict resting on ground truth to claim.
4316                false,
4317                reason,
4318                run_state.turns,
4319                evidence,
4320                outcome,
4321            );
4322        }
4323
4324        // Gather ground truth and evaluate the deterministic condition. Bounded
4325        // (car#1112): this iteration already produced a real reply in `outcome`
4326        // — that reply is owed to the caller regardless of whether the
4327        // condition can be graded, so a `gather` that never resolves must halt
4328        // immediately (fail open) rather than hang the turn or burn the rest
4329        // of the iteration budget re-running the model against a condition
4330        // that structurally can never be evaluated.
4331        let g = match tokio::time::timeout(GOAL_EVALUATION_TIMEOUT, gather(&outcome)).await {
4332            Ok(g) => g,
4333            Err(_) => {
4334                let reason = format!(
4335                    "goal check did not complete within {}s — treating this turn's reply as \
4336                     unevaluated rather than blocking on it",
4337                    GOAL_EVALUATION_TIMEOUT.as_secs()
4338                );
4339                // `grounded: false` (car#1113 review), not `true`: `grounded`
4340                // means the verdict rests on deterministic ground truth, but
4341                // there IS no verdict here — the check never ran. `grounded:
4342                // true` next to `met: false` reads on the wire as
4343                // "deterministically evaluated, definitively not met", which
4344                // is the opposite of what this halt means. Confirmed harmless
4345                // to flip: `met: false` alone already keeps
4346                // `ChatGoalState.status` at `"running"` in
4347                // `handler::update_chat_goal_from_event`'s `goal_evaluated`
4348                // arm (it only reads `grounded` when `met == true`), and this
4349                // arm returns immediately after, so no loop logic downstream
4350                // consumes the flipped value either.
4351                let verdict = GoalVerdict {
4352                    met: false,
4353                    grounded: false,
4354                    reason: reason.clone(),
4355                };
4356                evidence.push(verdict.clone());
4357                runtime
4358                    .record_goal_evaluated(
4359                        &spec.goal,
4360                        &spec.condition,
4361                        run_state.turns,
4362                        verdict.met,
4363                        verdict.grounded,
4364                        &verdict.reason,
4365                        &outcome.model_used,
4366                    )
4367                    .await;
4368                tracing::warn!(
4369                    target: "car::goal",
4370                    iteration = run_state.turns,
4371                    timeout_secs = GOAL_EVALUATION_TIMEOUT.as_secs(),
4372                    "goal evaluation timed out — halting with the primary reply intact"
4373                );
4374                emit(AssistantEvent::GoalEvaluated {
4375                    iteration: run_state.turns,
4376                    met: false,
4377                    grounded: false,
4378                    reason: reason.clone(),
4379                });
4380                return finish(
4381                    GoalStatus::Halted {
4382                        halt: GoalHalt::EvaluationTimeout,
4383                    },
4384                    false,
4385                    reason,
4386                    run_state.turns,
4387                    evidence,
4388                    outcome,
4389                );
4390            }
4391        };
4392        let inputs = runtime.gather_goal_inputs(&g).await;
4393
4394        // Pre-grounding verdict. `base.met && base.grounded` is exactly "the
4395        // deterministic check passed": a met verdict is grounded iff it rested
4396        // only on deterministic leaves (Command / StatePredicate / receipts / …);
4397        // a met verdict that leaned on a `ModelJudge` is grounded=false.
4398        let base = evaluate_goal(&spec.condition, &inputs);
4399        let verdict = if base.met && base.grounded {
4400            // Deterministic pass: ground truth already verified completion, so
4401            // final-summary claim grounding is DEMOTED from an authority (it used
4402            // to flip grounded=false and re-drive the loop on word choice — F9) to
4403            // a reply annotation. Keep grounded=true; if the prose named an
4404            // operational claim with no matching same-run receipt, log it and note
4405            // it on the returned reply text ONLY — never touch `messages`
4406            // (persisted into the session thread) or the verdict's grounded flag /
4407            // durable event. A deterministically-verified completion is not the
4408            // false-completion pattern the Phase-0 miners look for, so the prose
4409            // mismatch is logged at info, not recorded as a failure signal.
4410            let ungrounded = ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts);
4411            if !ungrounded.is_empty() {
4412                tracing::info!(
4413                    target: "car::goal",
4414                    iteration = run_state.turns,
4415                    claims = %ungrounded.join(", "),
4416                    "deterministic goal check passed; final-summary claim(s) unmatched \
4417                     to a tool receipt — annotating reply, keeping grounded=true"
4418                );
4419                outcome.summary = annotate_summary_with_claim_note(&outcome.summary, &ungrounded);
4420            }
4421            base
4422        } else {
4423            // Not a deterministic pass (unmet, or met only via a `ModelJudge`):
4424            // UNCHANGED behavior — claim grounding retains its authority to flip
4425            // grounded=false and concat the reason, the loop keeps iterating, and
4426            // the Phase-0 miners keep receiving the ungrounded `GoalEvaluated`
4427            // signal (harness_adapt ungrounded-completion, evolution failure fold).
4428            apply_summary_claim_grounding(base, &outcome)
4429        };
4430        evidence.push(verdict.clone());
4431        runtime
4432            .record_goal_evaluated(
4433                &spec.goal,
4434                &spec.condition,
4435                run_state.turns,
4436                verdict.met,
4437                verdict.grounded,
4438                &verdict.reason,
4439                &outcome.model_used,
4440            )
4441            .await;
4442        // Audit and stream the "why continue?" decision. The typed event-log
4443        // entry above is durable; this tracing/UI event is for live operators.
4444        tracing::info!(
4445            target: "car::goal",
4446            iteration = run_state.turns,
4447            met = verdict.met,
4448            grounded = verdict.grounded,
4449            reason = %verdict.reason,
4450            "goal evaluated"
4451        );
4452        emit(AssistantEvent::GoalEvaluated {
4453            iteration: run_state.turns,
4454            met: verdict.met,
4455            grounded: verdict.grounded,
4456            reason: verdict.reason.clone(),
4457        });
4458
4459        if verdict.met && verdict.grounded {
4460            return finish(
4461                GoalStatus::Achieved,
4462                verdict.grounded,
4463                verdict.reason,
4464                run_state.turns,
4465                evidence,
4466                outcome,
4467            );
4468        }
4469        last_reason = verdict.reason;
4470        last_outcome = outcome;
4471    }
4472}
4473
4474#[cfg(test)]
4475mod tests {
4476    use super::*;
4477    use crate::assistant::executor::GeneralExecutor;
4478    use async_trait::async_trait;
4479    use car_engine::{LocalSubstrate, Runtime, Substrate, ToolExecutor};
4480    use car_inference::{InferenceEngine, InferenceError, InferenceResult};
4481    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4482    use std::sync::{Arc, Mutex as StdMutex};
4483
4484    // ---- observation truncation (#813) ----
4485
4486    /// Truncation must state its magnitude. The old marker was a bare
4487    /// `…[truncated]…`, so a model could not tell whether it had lost 10 bytes
4488    /// or 10 MB, and a clipped table read as a complete one.
4489    ///
4490    /// This does NOT make truncation non-destructive — the elided bytes are
4491    /// still gone. That is the session-value-store work in #813, which needs
4492    /// its own design pass. This only makes the loss legible.
4493    #[test]
4494    fn truncation_reports_true_size_and_elided_amount() {
4495        let total = OBSERVATION_CAP + 5_000;
4496        let out = cap("x".repeat(total));
4497
4498        assert!(
4499            out.contains(&format!("of {total} bytes")),
4500            "the TRUE size must be reported, not just the fact of truncation: {}",
4501            &out[out.len().saturating_sub(200)..]
4502        );
4503        assert!(
4504            out.contains("5000 bytes elided"),
4505            "the elided amount must be reported so the model can judge the loss: {}",
4506            &out[out.len().saturating_sub(200)..]
4507        );
4508        assert!(
4509            out.contains("NOT retained"),
4510            "the model must be told re-running is the only recovery"
4511        );
4512        // The payload itself is still bounded — the notice is additive.
4513        assert!(out.starts_with(&"x".repeat(1_000)));
4514    }
4515
4516    /// An observation at or under the cap must pass through untouched — no
4517    /// notice, no allocation of a truncation message.
4518    #[test]
4519    fn observations_within_the_cap_are_unmodified() {
4520        let small = "y".repeat(OBSERVATION_CAP);
4521        assert_eq!(cap(small.clone()), small);
4522        let tiny = "hello".to_string();
4523        assert_eq!(cap(tiny.clone()), tiny);
4524    }
4525
4526    /// Truncation must land on a char boundary — a multi-byte payload clipped
4527    /// mid-codepoint would panic on `truncate`.
4528    #[test]
4529    fn truncation_respects_char_boundaries() {
4530        // 3-byte chars, so OBSERVATION_CAP (16384) is not a boundary multiple.
4531        let s = "€".repeat(OBSERVATION_CAP);
4532        let out = cap(s);
4533        assert!(out.contains("bytes elided"));
4534        assert!(out.is_char_boundary(0));
4535    }
4536
4537    // ---- no-progress guard ----
4538
4539    #[test]
4540    fn guard_breaks_on_repeated_mutation_not_just_reads() {
4541        // The bug: a "mutating" tool (e.g. remember) called with identical args
4542        // every turn reset the guard forever and ran to max_turns. A repeated
4543        // identical call is idempotent — no new progress — so it must trip
4544        // STALL_BREAK like any other stall.
4545        let mut g = NoProgressGuard::default();
4546        // First remember of this fact IS progress (new signature).
4547        assert_eq!(
4548            g.observe("remember({\"body\":\"x\"})", true),
4549            GuardStep::Progress
4550        );
4551        // Re-remembering the same fact makes no progress; it accumulates to a
4552        // hard stop rather than resetting.
4553        let sig = "remember({\"body\":\"x\"})";
4554        let mut steps = vec![];
4555        for _ in 0..STALL_BREAK {
4556            steps.push(g.observe(sig, true));
4557        }
4558        assert!(
4559            steps.contains(&GuardStep::Break),
4560            "repeated identical mutation must eventually Break, got {steps:?}"
4561        );
4562        assert!(
4563            steps.contains(&GuardStep::Nudge),
4564            "should nudge before breaking"
4565        );
4566    }
4567
4568    #[test]
4569    fn guard_treats_distinct_mutations_as_progress() {
4570        // Remembering several DIFFERENT facts is real work — never a stall.
4571        let mut g = NoProgressGuard::default();
4572        for i in 0..20 {
4573            let sig = format!("remember({{\"body\":\"fact-{i}\"}})");
4574            assert_eq!(g.observe(&sig, true), GuardStep::Progress);
4575        }
4576    }
4577
4578    #[test]
4579    fn guard_read_only_repeat_still_breaks() {
4580        // Non-mutating behavior is unchanged: a repeated read loop stalls out.
4581        let mut g = NoProgressGuard::default();
4582        let mut steps = vec![];
4583        for _ in 0..(STALL_BREAK + 1) {
4584            steps.push(g.observe("recall({\"q\":\"x\"})", false));
4585        }
4586        assert!(steps.contains(&GuardStep::Break));
4587    }
4588
4589    #[test]
4590    fn guard_read_only_task_never_hard_stops_without_repeat() {
4591        // Distinct reads never repeat a signature, so they only ever earn the
4592        // soft EXPLORE_NUDGE — never a Break (a genuinely read-only task is legit).
4593        let mut g = NoProgressGuard::default();
4594        let mut steps = vec![];
4595        for i in 0..(EXPLORE_NUDGE + 5) {
4596            steps.push(g.observe(&format!("read_file({{\"p\":\"f{i}\"}})"), false));
4597        }
4598        assert!(
4599            !steps.contains(&GuardStep::Break),
4600            "distinct reads must not Break"
4601        );
4602        assert!(
4603            steps.contains(&GuardStep::Nudge),
4604            "should soft-nudge after EXPLORE_NUDGE"
4605        );
4606    }
4607
4608    // ---- history compaction (context-window bound) ----
4609
4610    fn sys(t: &str) -> Message {
4611        Message::System { content: t.into() }
4612    }
4613    fn usr(t: &str) -> Message {
4614        Message::User { content: t.into() }
4615    }
4616    fn asst_call(id: &str) -> Message {
4617        Message::Assistant {
4618            content: String::new(),
4619            tool_calls: vec![serde_json::from_value(json!({
4620                "name": "write_file",
4621                "arguments": {"path": "a.js"},
4622                "id": id
4623            }))
4624            .unwrap()],
4625            thinking: vec![],
4626            model_id: None,
4627            local_last_resort: false,
4628        }
4629    }
4630    fn tool_res(id: &str, body: &str) -> Message {
4631        Message::ToolResult {
4632            tool_use_id: id.into(),
4633            content: body.into(),
4634            provenance: Default::default(),
4635        }
4636    }
4637    fn provider_item(id: &str, body: &str) -> Message {
4638        Message::ProviderOutputItems {
4639            protocol: car_inference::protocol::OPENAI_RESPONSES_PROTOCOL.into(),
4640            items: vec![json!({
4641                "type": "reasoning",
4642                "id": id,
4643                "status": "completed",
4644                "encrypted_content": body,
4645            })],
4646        }
4647    }
4648
4649    #[test]
4650    fn transcript_receipts_do_not_trust_repeated_provider_ids() {
4651        let call = |expression: &str| {
4652            serde_json::from_value(json!({
4653                "name": "calculate",
4654                "arguments": {"expression": expression},
4655                "id": "repeated"
4656            }))
4657            .unwrap()
4658        };
4659        let messages = vec![
4660            Message::Assistant {
4661                content: String::new(),
4662                tool_calls: vec![call("2+2"), call("3+3")],
4663                thinking: vec![],
4664                model_id: None,
4665                local_last_resort: false,
4666            },
4667            tool_res("repeated", r#"{"result":4}"#),
4668            tool_res("repeated", r#"{"result":6}"#),
4669        ];
4670
4671        let receipts = transcript_tool_receipts(&messages);
4672        assert_eq!(receipts.len(), 2);
4673        assert_eq!(receipts[0].call_id.as_deref(), Some("turn_1_call_1"));
4674        assert_eq!(receipts[1].call_id.as_deref(), Some("turn_1_call_2"));
4675        assert_eq!(receipts[0].sequence, Some(1));
4676        assert_eq!(receipts[1].sequence, Some(2));
4677        assert_eq!(receipts[0].params["expression"], "2+2");
4678        assert_eq!(receipts[1].params["expression"], "3+3");
4679    }
4680
4681    /// A kept history must never begin a segment with an orphaned ToolResult
4682    /// (one whose Assistant call was dropped) — that is provider-invalid.
4683    fn no_orphan_tool_results(msgs: &[Message]) -> bool {
4684        let mut seen_call_ids: std::collections::HashSet<String> = Default::default();
4685        for m in msgs {
4686            match m {
4687                Message::Assistant { tool_calls, .. } => {
4688                    for c in tool_calls {
4689                        if let Some(id) = &c.id {
4690                            seen_call_ids.insert(id.clone());
4691                        }
4692                    }
4693                }
4694                Message::ToolResult { tool_use_id, .. } if !seen_call_ids.contains(tool_use_id) => {
4695                    return false;
4696                }
4697                _ => {}
4698            }
4699        }
4700        true
4701    }
4702
4703    #[test]
4704    fn mutating_tools_are_derived_from_metadata_plus_builtin_file_writers() {
4705        let tools = vec![
4706            json!({"name": "remember", "mutating": true}),
4707            json!({"name": "recall"}),
4708            json!({"name": "generate_image", "mutating": true}),
4709        ];
4710        let names = mutating_tool_names(&tools);
4711
4712        assert!(names.contains("write_file"));
4713        assert!(names.contains("edit_file"));
4714        assert!(names.contains("remember"));
4715        assert!(names.contains("generate_image"));
4716        assert!(!names.contains("recall"));
4717    }
4718
4719    #[test]
4720    fn compaction_is_noop_under_budget_and_when_window_unknown() {
4721        let mut m = vec![
4722            sys("s"),
4723            usr("task"),
4724            asst_call("c1"),
4725            tool_res("c1", "small"),
4726        ];
4727        let before = m.clone();
4728        compact_history_to_window(&mut m, 128_000); // tiny history, huge window
4729        assert_eq!(m, before, "under-budget history must be untouched");
4730        compact_history_to_window(&mut m, 0); // unknown window
4731        assert_eq!(m, before, "unknown window must be a no-op");
4732    }
4733
4734    #[test]
4735    fn compaction_pins_system_and_task_keeps_tail_no_orphans() {
4736        let big = "x".repeat(20_000); // ~5k tokens each
4737        let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
4738        for i in 0..12 {
4739            m.push(asst_call(&format!("c{i}")));
4740            m.push(tool_res(&format!("c{i}"), &big));
4741        }
4742        let window = 20_000; // budget = 15k tokens — forces heavy trimming
4743        compact_history_to_window(&mut m, window);
4744
4745        // Pinned head survives.
4746        assert!(matches!(&m[0], Message::System { .. }), "system pinned");
4747        assert!(
4748            matches!(&m[1], Message::User { content } if content == "THE ORIGINAL TASK"),
4749            "original task pinned"
4750        );
4751        // Recent tail survives (last exchange present).
4752        assert!(
4753            matches!(m.last(), Some(Message::ToolResult { tool_use_id, .. }) if tool_use_id == "c11"),
4754            "most-recent tool result kept"
4755        );
4756        // Structurally valid: no dangling tool results.
4757        assert!(
4758            no_orphan_tool_results(&m),
4759            "no orphaned tool results after trim"
4760        );
4761        // It actually shrank.
4762        assert!(m.len() < 26, "history was compacted (was 26 msgs)");
4763    }
4764
4765    /// #814 items 2-3 — durable state must reach the model without it having to
4766    /// ask, and must land at the TAIL so the cached prefix stays byte-stable.
4767    ///
4768    /// Appending to the last message rather than adding a trailing
4769    /// `Message::System` is what makes the tail placement real: the Anthropic
4770    /// and Gemini handlers fold every System message into the top-level system
4771    /// field, so a trailing System block would land in the cached PREFIX on two
4772    /// of three providers — the exact invalidation item 3 exists to prevent.
4773    #[test]
4774    fn state_block_lands_at_the_tail_inside_the_last_message() {
4775        let mut messages = vec![
4776            sys("system prompt"),
4777            usr("do the thing"),
4778            tool_res("c1", "tool output here"),
4779        ];
4780        let before_prefix = format!("{:?}{:?}", messages[0], messages[1]);
4781
4782        append_state_block(&mut messages, "todo: 1/3 done\n  [ ] 2 wire the CLI");
4783
4784        // The block is inside the LAST message…
4785        let Message::ToolResult { content, .. } = &messages[2] else {
4786            panic!("last message should still be the tool result");
4787        };
4788        assert!(content.starts_with("tool output here"), "{content}");
4789        assert!(
4790            content.contains("wire the CLI"),
4791            "state must be present: {content}"
4792        );
4793        // …fenced, so it cannot read as part of the tool's own output.
4794        assert!(content.contains("<runtime-state>"), "{content}");
4795        assert!(content.contains("</runtime-state>"), "{content}");
4796        // …and no message was added or reordered.
4797        assert_eq!(messages.len(), 3);
4798        // The prefix is untouched — this is the property item 3 is about.
4799        assert_eq!(
4800            before_prefix,
4801            format!("{:?}{:?}", messages[0], messages[1]),
4802            "appending state must not perturb the cached prefix"
4803        );
4804    }
4805
4806    /// The block is regenerated every turn, so it must never be persisted —
4807    /// otherwise stale copies stack up in the history, one per turn.
4808    #[tokio::test]
4809    async fn state_block_never_enters_the_durable_history() {
4810        let dir = tempfile::tempdir().unwrap();
4811        let rt = runtime_for(dir.path()).await;
4812        let todos = Arc::new(tokio::sync::Mutex::new(super::super::todo::TodoList::new()));
4813        todos
4814            .lock()
4815            .await
4816            .write(&[json!({"text": "wire the CLI"})])
4817            .unwrap();
4818
4819        let seen = Arc::new(StdMutex::new(Vec::new()));
4820        let script = CapturingScript {
4821            turns: vec![turn("done", json!([]))],
4822            cursor: AtomicUsize::new(0),
4823            seen: Arc::clone(&seen),
4824        };
4825        let mut messages = vec![sys("sys"), usr("do it")];
4826        let mut cfg = cfg();
4827        cfg.todos = Some(Arc::clone(&todos));
4828        run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
4829
4830        // The model saw it…
4831        let sent = seen.lock().unwrap();
4832        let sent_msgs = sent[0].messages.as_ref().expect("messages sent");
4833        let tail = format!("{:?}", sent_msgs.last().unwrap());
4834        assert!(
4835            tail.contains("wire the CLI"),
4836            "the model must see live state: {tail}"
4837        );
4838
4839        // …and the stored history did not keep it.
4840        assert!(
4841            !messages
4842                .iter()
4843                .any(|m| format!("{m:?}").contains("<runtime-state>")),
4844            "the block must not persist into history, or it stacks one copy per turn"
4845        );
4846    }
4847
4848    /// An empty plan renders nothing at all — no fence, no tokens, no cache
4849    /// churn for a block with no content.
4850    #[tokio::test]
4851    async fn no_state_block_when_there_is_nothing_to_say() {
4852        let dir = tempfile::tempdir().unwrap();
4853        let rt = runtime_for(dir.path()).await;
4854        let seen = Arc::new(StdMutex::new(Vec::new()));
4855        let script = CapturingScript {
4856            turns: vec![turn("done", json!([]))],
4857            cursor: AtomicUsize::new(0),
4858            seen: Arc::clone(&seen),
4859        };
4860        let mut messages = vec![sys("sys"), usr("do it")];
4861        let mut cfg = cfg();
4862        cfg.todos = Some(Arc::new(tokio::sync::Mutex::new(
4863            super::super::todo::TodoList::new(),
4864        )));
4865        run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
4866
4867        let sent = seen.lock().unwrap();
4868        let all = format!("{:?}", sent[0].messages);
4869        assert!(
4870            !all.contains("<runtime-state>"),
4871            "empty plan must render nothing: {all}"
4872        );
4873    }
4874
4875    fn remember_receipt(subject: &str, ok: bool) -> AssistantToolReceipt {
4876        AssistantToolReceipt {
4877            tool: "remember".to_string(),
4878            call_id: None,
4879            sequence: None,
4880            ok,
4881            params: json!({"subject": subject, "body": "…"}),
4882            result: None,
4883            via: None,
4884        }
4885    }
4886
4887    /// #814 — the recall-discipline gap the issue is actually about.
4888    ///
4889    /// A fact written at turn 3 was invisible at turn 7 unless the model
4890    /// independently decided to `recall`. Surfacing the subject does not hand it
4891    /// the content, but it does mean the model no longer has to *remember that
4892    /// it remembered* — the recall becomes informed rather than speculative.
4893    #[test]
4894    fn written_facts_reach_the_state_block_without_being_asked_for() {
4895        let receipts = [
4896            remember_receipt("deploy target", true),
4897            AssistantToolReceipt {
4898                tool: "read_file".to_string(),
4899                call_id: None,
4900                sequence: None,
4901                ok: true,
4902                params: json!({"path": "x"}),
4903                result: None,
4904                via: None,
4905            },
4906            remember_receipt("user timezone", true),
4907        ];
4908
4909        let subjects = recent_fact_subjects(&receipts);
4910        assert_eq!(subjects, vec!["deploy target", "user timezone"]);
4911
4912        let block = render_state_block(None, &subjects).expect("facts alone must render a block");
4913        assert!(block.contains("deploy target"), "{block}");
4914        assert!(block.contains("user timezone"), "{block}");
4915        // Subjects only: the bodies stay in memgine, which ranks them.
4916        assert!(
4917            block.contains("recall"),
4918            "must point at the content: {block}"
4919        );
4920        assert!(!block.contains('…'), "bodies must not be inlined: {block}");
4921    }
4922
4923    /// A `remember` the runtime REJECTED wrote nothing. Listing it would tell
4924    /// the model it knows something it does not — worse than saying nothing,
4925    /// because it suppresses the retry.
4926    #[test]
4927    fn a_failed_remember_is_not_reported_as_known() {
4928        let receipts = [
4929            remember_receipt("landed fact", true),
4930            remember_receipt("rejected fact", false),
4931        ];
4932        assert_eq!(recent_fact_subjects(&receipts), vec!["landed fact"]);
4933    }
4934
4935    /// A re-remember supersedes the earlier write rather than adding a second
4936    /// fact, so the subject must move — not duplicate, which would both inflate
4937    /// the count and spend the cap on one subject.
4938    #[test]
4939    fn re_remembering_a_subject_moves_it_instead_of_duplicating() {
4940        let receipts = [
4941            remember_receipt("api base url", true),
4942            remember_receipt("deploy target", true),
4943            remember_receipt("api base url", true),
4944        ];
4945        assert_eq!(
4946            recent_fact_subjects(&receipts),
4947            vec!["deploy target", "api base url"]
4948        );
4949    }
4950
4951    /// The block is bounded: a run that remembers 40 things must not turn the
4952    /// tail into the largest part of the request. The most RECENT survive.
4953    #[test]
4954    fn the_fact_list_is_bounded_and_says_what_it_dropped() {
4955        let subjects: Vec<String> = (0..12).map(|i| format!("fact {i}")).collect();
4956        let block = render_state_block(None, &subjects).expect("must render");
4957
4958        assert!(block.contains("fact 11"), "newest must survive: {block}");
4959        assert!(!block.contains("fact 6"), "oldest must be cut: {block}");
4960        assert!(
4961            block.contains("+7 earlier"),
4962            "a silent cut reads as 'that's all there is': {block}"
4963        );
4964    }
4965
4966    /// Both sections are independent: either one alone renders, and neither
4967    /// renders an empty fence.
4968    #[test]
4969    fn sections_render_independently_and_nothing_renders_nothing() {
4970        assert!(render_state_block(None, &[]).is_none());
4971        assert!(render_state_block(Some("todo: 0/1 done".into()), &[]).is_some());
4972        assert!(render_state_block(None, &["a fact".to_string()]).is_some());
4973
4974        let both = render_state_block(Some("todo: 0/1 done".into()), &["a fact".to_string()])
4975            .expect("must render");
4976        assert!(both.contains("todo:"), "{both}");
4977        assert!(both.contains("a fact"), "{both}");
4978    }
4979
4980    /// Parslee-ai/car#815 — compaction must not be invisible.
4981    ///
4982    /// Turns used to simply cease to exist between one request and the next,
4983    /// so a run that degraded afterwards looked, in the trace, exactly like a
4984    /// model that got worse. "The model forgot" and "the harness deleted it"
4985    /// are different bugs with different fixes.
4986    #[test]
4987    fn compaction_leaves_a_marker_the_model_can_see() {
4988        let big = "x".repeat(20_000);
4989        let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
4990        for i in 0..12 {
4991            m.push(asst_call(&format!("c{i}")));
4992            m.push(tool_res(&format!("c{i}"), &big));
4993        }
4994        compact_history_to_window(&mut m, 20_000);
4995
4996        let notice = m
4997            .iter()
4998            .find_map(|msg| match msg {
4999                Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX) => {
5000                    Some(content.clone())
5001                }
5002                _ => None,
5003            })
5004            .expect("a compaction notice must be left in place of the removed turns");
5005
5006        assert!(
5007            notice.contains("earlier turns removed"),
5008            "the notice must say turns were removed: {notice}"
5009        );
5010        assert!(
5011            notice.contains("events_query"),
5012            "a notice that says something is missing without saying how to look \
5013             only turns a silent failure into a visible dead end: {notice}"
5014        );
5015        // It sits at the head, where the removal happened — not appended at the
5016        // end, where it would read as a fact about the latest turn.
5017        assert!(
5018            matches!(&m[2], Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX)),
5019            "notice belongs where the turns were, after system + task"
5020        );
5021    }
5022
5023    /// A second compaction must UPDATE the notice, not erase it or stack a
5024    /// second one. Erasing it would restore the exact silent-deletion property
5025    /// the marker exists to prevent — and the erasure would happen precisely in
5026    /// the long runs that need the signal most.
5027    #[test]
5028    fn repeated_compaction_accumulates_into_one_notice() {
5029        let big = "x".repeat(20_000);
5030        let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
5031        for i in 0..12 {
5032            m.push(asst_call(&format!("c{i}")));
5033            m.push(tool_res(&format!("c{i}"), &big));
5034        }
5035        compact_history_to_window(&mut m, 20_000);
5036        let (first_turns, first_tokens) =
5037            parse_compaction_notice(&m[2]).expect("first notice parses");
5038
5039        // Grow the history again and re-compact.
5040        for i in 12..24 {
5041            m.push(asst_call(&format!("c{i}")));
5042            m.push(tool_res(&format!("c{i}"), &big));
5043        }
5044        compact_history_to_window(&mut m, 20_000);
5045
5046        let notices: Vec<&String> = m
5047            .iter()
5048            .filter_map(|msg| match msg {
5049                Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX) => {
5050                    Some(content)
5051                }
5052                _ => None,
5053            })
5054            .collect();
5055        assert_eq!(
5056            notices.len(),
5057            1,
5058            "exactly one notice, not a stack: {notices:?}"
5059        );
5060
5061        let (turns, tokens) = parse_compaction_notice(&m[2]).expect("notice still parses");
5062        assert!(
5063            turns > first_turns && tokens > first_tokens,
5064            "totals must accumulate across compactions ({first_turns}/{first_tokens} \
5065             -> {turns}/{tokens})"
5066        );
5067    }
5068
5069    /// The marker round-trips through its own text. If this drifts, repeated
5070    /// compaction silently resets the running totals to the latest pass.
5071    #[test]
5072    fn compaction_notice_round_trips() {
5073        // Arity only: the helper gained a recovery arm for the declarative
5074        // runner. `default()` IS this loop's existing text — see
5075        // `the_assistant_loops_compaction_notice_text_is_unchanged`.
5076        let rendered = format_compaction_notice(12, 34_000, CompactionRecovery::default());
5077        let parsed = parse_compaction_notice(&Message::System { content: rendered });
5078        assert_eq!(parsed, Some((12, 34_000)));
5079        // Anything else is not a notice.
5080        assert_eq!(
5081            parse_compaction_notice(&sys("ordinary system prompt")),
5082            None
5083        );
5084        assert_eq!(parse_compaction_notice(&usr("a user turn")), None);
5085    }
5086
5087    #[test]
5088    fn compaction_keeps_responses_item_with_its_assistant_turn() {
5089        let big = "x".repeat(20_000);
5090        let mut messages = vec![sys("system"), usr("THE ORIGINAL TASK")];
5091        for i in 0..12 {
5092            messages.push(provider_item(&format!("rs_{i}"), &big));
5093            messages.push(asst_call(&format!("c{i}")));
5094            messages.push(tool_res(&format!("c{i}"), "ok"));
5095        }
5096
5097        compact_history_to_window(&mut messages, 20_000);
5098
5099        for (index, message) in messages.iter().enumerate() {
5100            if matches!(message, Message::ProviderOutputItems { .. }) {
5101                assert!(
5102                    matches!(messages.get(index + 1), Some(Message::Assistant { .. })),
5103                    "provider continuity item was orphaned from its assistant"
5104                );
5105            }
5106        }
5107        assert!(
5108            no_orphan_tool_results(&messages),
5109            "compacted history contains an orphan tool result"
5110        );
5111    }
5112
5113    /// End-to-end wiring: the real assistant loop, driven by a generator with a
5114    /// small window that emits a large assistant message each turn, must bound
5115    /// the running history — proving the loop calls the compactor with the
5116    /// model's window every turn (the fix that eliminates the `available_tokens=0`
5117    /// overflow). Deterministic — no live model.
5118    #[tokio::test]
5119    async fn loop_compacts_history_to_window() {
5120        let dir = tempfile::tempdir().unwrap();
5121        let rt = runtime_for(dir.path()).await;
5122
5123        // Window 4000 → compaction budget 3000 tokens. Each turn emits ~2000
5124        // tokens of assistant text + a tiny tool call, so the raw history would
5125        // blow past the window within a few turns.
5126        struct WindowedBig {
5127            cursor: AtomicUsize,
5128        }
5129        #[async_trait]
5130        impl TurnGenerator for WindowedBig {
5131            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5132                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5133                if i < 6 {
5134                    // Distinct args each turn so this exercises compaction only,
5135                    // not the (separate) no-progress repeat guard.
5136                    Ok(turn(
5137                        &"x".repeat(8000),
5138                        json!([{ "id": format!("c{i}"), "name": "calculate",
5139                                 "arguments": { "expression": format!("1+{i}") } }]),
5140                    ))
5141                } else {
5142                    Ok(turn("done", json!([])))
5143                }
5144            }
5145            fn context_window(&self, _model: &str) -> usize {
5146                4000
5147            }
5148        }
5149
5150        let generator = WindowedBig {
5151            cursor: AtomicUsize::new(0),
5152        };
5153        let mut messages = vec![
5154            Message::System {
5155                content: "system".into(),
5156            },
5157            Message::User {
5158                content: "THE TASK".into(),
5159            },
5160        ];
5161        let mut c = cfg();
5162        c.max_turns = 8;
5163
5164        let out = run_assistant_loop(&generator, &rt, &c, &mut messages, |_e| {}).await;
5165
5166        assert_eq!(out.status, "success");
5167        // Uncompacted this run would leave ~14 messages; compaction keeps the
5168        // pinned head + a recent tail, so it is materially bounded.
5169        assert!(
5170            messages.len() <= 11,
5171            "history bounded by compaction, got {} messages",
5172            messages.len()
5173        );
5174        assert!(
5175            matches!(&messages[0], Message::System { .. }),
5176            "system stays pinned"
5177        );
5178        assert!(
5179            matches!(&messages[1], Message::User { content } if content == "THE TASK"),
5180            "original task stays pinned"
5181        );
5182        assert!(
5183            no_orphan_tool_results(&messages),
5184            "no orphaned tool results in the live loop"
5185        );
5186    }
5187
5188    /// The no-progress guard: a model stuck re-reading the same file (the
5189    /// observed gpt-5.x pathology — 49 reads, 0 writes) must be halted as
5190    /// `stalled`, well before the turn cap, instead of burning the whole budget.
5191    #[tokio::test]
5192    async fn loop_halts_a_no_progress_repeat_loop() {
5193        let dir = tempfile::tempdir().unwrap();
5194        let rt = runtime_for(dir.path()).await;
5195
5196        struct Stuck;
5197        #[async_trait]
5198        impl TurnGenerator for Stuck {
5199            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5200                // The identical read-only action, forever.
5201                Ok(turn(
5202                    "re-reading",
5203                    json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
5204                ))
5205            }
5206        }
5207
5208        let mut messages = vec![
5209            Message::System {
5210                content: "sys".into(),
5211            },
5212            Message::User {
5213                content: "task".into(),
5214            },
5215        ];
5216        let mut c = cfg();
5217        c.max_turns = 40; // high on purpose: the guard, not the cap, must stop it
5218
5219        let out = run_assistant_loop(&Stuck, &rt, &c, &mut messages, |_e| {}).await;
5220
5221        assert_eq!(
5222            out.status, "stalled",
5223            "a no-progress loop must halt as `stalled`, not run to max_turns"
5224        );
5225        assert!(
5226            out.turns < 40,
5227            "must stop well before the turn cap, got {} turns",
5228            out.turns
5229        );
5230    }
5231
5232    /// A read + read-only-shell cycle (re-read a file, `wc` it, re-read, `wc`…)
5233    /// makes no state change. Because `shell` is not a state-mutating tool, it no
5234    /// longer resets the guard, so this cycle is caught — the exact hole that let
5235    /// the observed run interleave `shell(wc)` between reads and loop forever.
5236    #[tokio::test]
5237    async fn loop_halts_a_read_plus_readonly_shell_cycle() {
5238        let dir = tempfile::tempdir().unwrap();
5239        let rt = runtime_for(dir.path()).await;
5240
5241        struct Cycle {
5242            cursor: AtomicUsize,
5243        }
5244        #[async_trait]
5245        impl TurnGenerator for Cycle {
5246            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5247                let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5248                if i.is_multiple_of(2) {
5249                    Ok(turn(
5250                        "read",
5251                        json!([{ "name": "read_file", "arguments": { "path": "app.js" } }]),
5252                    ))
5253                } else {
5254                    Ok(turn(
5255                        "probe",
5256                        json!([{ "name": "shell", "arguments": { "command": "wc -l app.js" } }]),
5257                    ))
5258                }
5259            }
5260        }
5261
5262        let mut messages = vec![
5263            Message::System {
5264                content: "sys".into(),
5265            },
5266            Message::User {
5267                content: "task".into(),
5268            },
5269        ];
5270        let mut c = cfg();
5271        c.max_turns = 40;
5272
5273        let out = run_assistant_loop(
5274            &Cycle {
5275                cursor: AtomicUsize::new(0),
5276            },
5277            &rt,
5278            &c,
5279            &mut messages,
5280            |_e| {},
5281        )
5282        .await;
5283
5284        assert_eq!(
5285            out.status, "stalled",
5286            "a read/read-only-shell cycle with no file change must halt"
5287        );
5288        assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
5289    }
5290
5291    /// A repeatedly-*failing* mutation is not progress. The model asks for the
5292    /// identical `write_file` every turn but it's rejected (escapes the root),
5293    /// so nothing ever changes. The guard must key on mutation SUCCESS, not the
5294    /// mere request, and halt — the "40 failed writes" twin of the read loop.
5295    #[tokio::test]
5296    async fn loop_halts_a_repeatedly_failing_mutation() {
5297        let dir = tempfile::tempdir().unwrap();
5298        let rt = runtime_for(dir.path()).await;
5299
5300        struct FailWrite;
5301        #[async_trait]
5302        impl TurnGenerator for FailWrite {
5303            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5304                // Escapes the clamped root every time → the executor rejects it,
5305                // so it is a mutating *request* that never *succeeds*.
5306                Ok(turn(
5307                    "writing",
5308                    json!([{ "name": "write_file",
5309                             "arguments": { "path": "../../etc/evil", "content": "x" } }]),
5310                ))
5311            }
5312        }
5313
5314        let mut messages = vec![
5315            Message::System {
5316                content: "sys".into(),
5317            },
5318            Message::User {
5319                content: "task".into(),
5320            },
5321        ];
5322        let mut c = cfg();
5323        c.max_turns = 40;
5324
5325        let out = run_assistant_loop(&FailWrite, &rt, &c, &mut messages, |_e| {}).await;
5326
5327        assert_eq!(
5328            out.status, "stalled",
5329            "a repeatedly-failing mutation makes no progress and must halt (not reset the guard)"
5330        );
5331        assert!(out.turns < 40, "stopped before the cap, got {}", out.turns);
5332    }
5333
5334    /// A scripted turn that reports token usage, the way every real provider
5335    /// does. `turn()` deliberately reports none, so the two together cover the
5336    /// measured and unmeasured halves of the `usage: Option` contract.
5337    fn turn_with_usage(
5338        text: &str,
5339        tool_calls: Value,
5340        prompt_tokens: u64,
5341        completion_tokens: u64,
5342    ) -> InferenceResult {
5343        serde_json::from_value(json!({
5344            "text": text,
5345            "tool_calls": tool_calls,
5346            "trace_id": "t",
5347            "model_used": "scripted",
5348            "latency_ms": 25,
5349            "usage": {
5350                "prompt_tokens": prompt_tokens,
5351                "completion_tokens": completion_tokens,
5352                "total_tokens": prompt_tokens + completion_tokens,
5353                "context_window": 8192,
5354            },
5355        }))
5356        .expect("scripted InferenceResult shape with usage")
5357    }
5358
5359    /// GAP 1, the load-bearing assertion: a completed assistant-loop run must
5360    /// write `InferenceMetered` events carrying real token counts, and
5361    /// `compute_harness_metrics` over that trajectory must report
5362    /// `model_calls > 0` and `total_tokens > 0`.
5363    ///
5364    /// Before this, the loop dropped `InferenceResult::usage` entirely, so a
5365    /// `HarnessMetrics` computed from an assistant journal was structurally
5366    /// blank — zero tokens, zero calls — and the Evolution Agent's regression
5367    /// gate could never fire its token-improvement branch. Offline: the
5368    /// generator is scripted, so this runs in CI with no API key.
5369    #[tokio::test]
5370    async fn assistant_loop_meters_every_model_call_with_real_tokens() {
5371        let dir = tempfile::tempdir().unwrap();
5372        let rt = runtime_for(dir.path()).await;
5373        // Turn 1: a real tool call. Turn 2: finish with prose after a
5374        // scripted on-device last-resort fallback.
5375        let mut fallback_turn = turn_with_usage("The answer is 42.", json!([]), 200, 15);
5376        fallback_turn.model_identity.resolved_model_id = "mlx/qwen3-4b:4bit".into();
5377        fallback_turn.local_last_resort = true;
5378        let script = Script {
5379            turns: vec![
5380                turn_with_usage(
5381                    "computing",
5382                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
5383                    120,
5384                    30,
5385                ),
5386                fallback_turn,
5387            ],
5388            cursor: AtomicUsize::new(0),
5389        };
5390        let mut messages = vec![
5391            Message::System {
5392                content: "sys".into(),
5393            },
5394            Message::User {
5395                content: "what is 6*7?".into(),
5396            },
5397        ];
5398        let mut assistant_events = Vec::new();
5399        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |event| {
5400            assistant_events.push(event)
5401        })
5402        .await;
5403        assert_eq!(outcome.status, "success");
5404        assert_eq!(
5405            outcome.models_served,
5406            vec![
5407                AssistantModelAttribution {
5408                    model_id: "scripted".into(),
5409                    local_last_resort: false,
5410                },
5411                AssistantModelAttribution {
5412                    model_id: "mlx/qwen3-4b:4bit".into(),
5413                    local_last_resort: true,
5414                },
5415            ],
5416            "the terminal run receipt must retain every turn, including the local fallback"
5417        );
5418        assert_eq!(
5419            outcome.model_used, "mlx/qwen3-4b:4bit",
5420            "final attribution must use the canonical resolved model id"
5421        );
5422
5423        let transcript_attributions: Vec<_> = messages
5424            .iter()
5425            .filter_map(|message| match message {
5426                Message::Assistant {
5427                    model_id,
5428                    local_last_resort,
5429                    ..
5430                } => Some((model_id.as_deref(), *local_last_resort)),
5431                _ => None,
5432            })
5433            .collect();
5434        assert_eq!(
5435            transcript_attributions,
5436            vec![(Some("scripted"), false), (Some("mlx/qwen3-4b:4bit"), true)],
5437            "the exact replayable transcript must carry each serving attribution"
5438        );
5439
5440        let attributions: Vec<_> = assistant_events
5441            .iter()
5442            .filter_map(|event| match event {
5443                AssistantEvent::ModelServed {
5444                    model_id,
5445                    local_last_resort,
5446                } => Some((model_id.as_str(), *local_last_resort)),
5447                _ => None,
5448            })
5449            .collect();
5450        assert_eq!(
5451            attributions,
5452            vec![("scripted", false), ("mlx/qwen3-4b:4bit", true)],
5453            "every completed assistant turn must emit its canonical serving model and fallback marker"
5454        );
5455
5456        let events = rt.log.lock().await.events().to_vec();
5457        let metered: Vec<_> = events
5458            .iter()
5459            .filter(|e| e.kind == car_eventlog::EventKind::InferenceMetered)
5460            .collect();
5461        assert_eq!(
5462            metered.len(),
5463            2,
5464            "one InferenceMetered per model call; the loop made 2 generate() calls"
5465        );
5466        let metered_models: Vec<_> = metered
5467            .iter()
5468            .map(|event| event.data.get("model_id").and_then(Value::as_str))
5469            .collect();
5470        assert_eq!(
5471            metered_models,
5472            vec![Some("scripted"), Some("mlx/qwen3-4b:4bit")],
5473            "metered events must use the same canonical serving ids"
5474        );
5475        for ev in &metered {
5476            assert_eq!(
5477                ev.data.get("usage_measured").and_then(|v| v.as_bool()),
5478                Some(true)
5479            );
5480        }
5481
5482        let m = car_eventlog::harness_metrics::compute_harness_metrics(&events);
5483        assert_eq!(
5484            m.trajectory_efficiency.model_calls, 2,
5485            "harness metrics must see the model calls"
5486        );
5487        assert_eq!(
5488            m.trajectory_efficiency.total_tokens,
5489            120 + 30 + 200 + 15,
5490            "tokens must be the sum of the scripted usage, not an estimate"
5491        );
5492        assert!(m.trajectory_efficiency.wall_clock_ms > 0.0);
5493
5494        // The gate at car_memgine::harness_evolution also needs the ACTION legs
5495        // (`actions_succeeded > 0` gates `candidate_did_work`, `success_rate` is
5496        // the only regression guard). The assistant loop routes tool calls
5497        // through `runtime.execute`, which meters them — assert that here so a
5498        // regression in either leg surfaces as a failure of THIS test rather
5499        // than as silently empty candidate metrics at promotion time.
5500        assert!(
5501            m.trajectory_efficiency.actions_succeeded > 0,
5502            "the executed `calculate` call must be recorded as a succeeded action; \
5503             got {m:?}"
5504        );
5505        assert!(
5506            m.trajectory_efficiency.success_rate.is_some(),
5507            "success_rate is the evolution gate's only regression guard and must be measured"
5508        );
5509    }
5510
5511    /// The `usage: Option` contract must survive into the journal: a provider
5512    /// that reports no usage still yields a counted model call, but must NOT
5513    /// fabricate zero tokens. `model_calls` and `total_tokens` therefore move
5514    /// independently — which is why the A/B reads both.
5515    #[tokio::test]
5516    async fn unmeasured_usage_still_counts_the_call_but_fabricates_no_tokens() {
5517        let dir = tempfile::tempdir().unwrap();
5518        let rt = runtime_for(dir.path()).await;
5519        // `turn()` reports no usage at all.
5520        let script = Script {
5521            turns: vec![turn("done, no usage reported", json!([]))],
5522            cursor: AtomicUsize::new(0),
5523        };
5524        let mut messages = vec![
5525            Message::System {
5526                content: "sys".into(),
5527            },
5528            Message::User {
5529                content: "hi".into(),
5530            },
5531        ];
5532        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
5533        assert_eq!(outcome.status, "success");
5534
5535        let events = rt.log.lock().await.events().to_vec();
5536        let metered: Vec<_> = events
5537            .iter()
5538            .filter(|e| e.kind == car_eventlog::EventKind::InferenceMetered)
5539            .collect();
5540        assert_eq!(metered.len(), 1, "the call happened, so it is counted");
5541        assert_eq!(
5542            metered[0]
5543                .data
5544                .get("usage_measured")
5545                .and_then(|v| v.as_bool()),
5546            Some(false),
5547            "the journal must say the count was unavailable, not imply a zero"
5548        );
5549
5550        let m = car_eventlog::harness_metrics::compute_harness_metrics(&events);
5551        assert_eq!(m.trajectory_efficiency.model_calls, 1);
5552        assert_eq!(
5553            m.trajectory_efficiency.total_tokens, 0,
5554            "no usage reported means no tokens attributed — absent, not invented"
5555        );
5556    }
5557
5558    fn turn(text: &str, tool_calls: Value) -> InferenceResult {
5559        serde_json::from_value(json!({
5560            "text": text,
5561            "tool_calls": tool_calls,
5562            "trace_id": "t",
5563            "model_used": "scripted",
5564            "latency_ms": 0,
5565        }))
5566        .expect("scripted InferenceResult shape")
5567    }
5568
5569    struct Script {
5570        turns: Vec<InferenceResult>,
5571        cursor: AtomicUsize,
5572    }
5573
5574    #[async_trait]
5575    impl TurnGenerator for Script {
5576        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5577            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5578            self.turns.get(i).cloned().ok_or("script exhausted".into())
5579        }
5580    }
5581
5582    struct CapturingGenerator {
5583        seen: Arc<StdMutex<Vec<GenerateRequest>>>,
5584    }
5585
5586    #[async_trait]
5587    impl TurnGenerator for CapturingGenerator {
5588        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5589            self.seen.lock().unwrap().push(req);
5590            Ok(turn("done", json!([])))
5591        }
5592    }
5593
5594    struct CapturingScript {
5595        turns: Vec<InferenceResult>,
5596        cursor: AtomicUsize,
5597        seen: Arc<StdMutex<Vec<GenerateRequest>>>,
5598    }
5599
5600    #[async_trait]
5601    impl TurnGenerator for CapturingScript {
5602        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
5603            self.seen.lock().unwrap().push(req);
5604            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
5605            self.turns.get(i).cloned().ok_or("script exhausted".into())
5606        }
5607    }
5608
5609    /// Build a real Runtime whose executor is a GeneralExecutor over a local
5610    /// substrate rooted at `dir` — the same wiring `build_assistant_runtime`
5611    /// produces, minus the network delegate.
5612    async fn runtime_for(dir: &std::path::Path) -> Runtime {
5613        let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
5614        let exec: Arc<dyn ToolExecutor> =
5615            Arc::new(GeneralExecutor::new(substrate.clone(), dir, true));
5616        let engine = Arc::new(InferenceEngine::new(Default::default()));
5617        let rt = Runtime::new()
5618            .with_inference(engine)
5619            .with_executor(exec)
5620            .with_substrate(substrate);
5621        rt.register_agent_basics().await;
5622        rt.register_tool_entry(
5623            car_engine::ToolEntry::builtin(car_ir::builtins::shell()).with_side_effects(true),
5624        )
5625        .await;
5626        rt
5627    }
5628
5629    fn cfg() -> AssistantConfig {
5630        AssistantConfig {
5631            model: Some("scripted".into()),
5632            strict_model: false,
5633            max_turns: 6,
5634            tools: GeneralExecutor::tool_defs(),
5635            gated_tools: Vec::new(),
5636            approval_policy: None,
5637            proactive_memory: None,
5638            tool_memory: None,
5639            // None => built-in labels, which cover the network-reaching
5640            // commodity tools. A caller that loads .car/tool-labels.json
5641            // should pass the merged map (car#723).
5642            tool_labels: None,
5643            todos: None,
5644            value_store_previews: false,
5645            response_format: None,
5646            context_window_override: None,
5647            refuse_unadvertised_tools: false,
5648            response_format_validator: None,
5649            delegate_budget: None,
5650        }
5651    }
5652
5653    #[derive(Default)]
5654    struct JsonProgress(StdMutex<Vec<Value>>);
5655
5656    impl super::super::do_json::EventSink for JsonProgress {
5657        fn emit(&self, event: Value) {
5658            self.0.lock().unwrap().push(event);
5659        }
5660    }
5661
5662    fn json_emitter(sink: Arc<JsonProgress>) -> Arc<super::super::do_json::JsonEmitter> {
5663        Arc::new(super::super::do_json::JsonEmitter::new(
5664            super::super::do_json::SandboxPosture {
5665                sandboxed: false,
5666                image: None,
5667                tier: "ReadOnly".into(),
5668                root: "/work".into(),
5669                mount: None,
5670                fallback_notice: None,
5671            },
5672            sink,
5673        ))
5674    }
5675
5676    #[tokio::test]
5677    async fn do_json_emits_inference_started_while_a_two_second_generation_is_in_flight() {
5678        struct Slow;
5679        #[async_trait]
5680        impl TurnGenerator for Slow {
5681            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5682                tokio::time::sleep(Duration::from_secs(2)).await;
5683                Ok(turn("done", json!([])))
5684            }
5685        }
5686
5687        let dir = tempfile::tempdir().unwrap();
5688        let rt = runtime_for(dir.path()).await;
5689        let sink = Arc::new(JsonProgress::default());
5690        let emitter = json_emitter(sink.clone());
5691        let mut messages = vec![sys("system"), usr("task")];
5692        let config = cfg();
5693        let run_emitter = emitter.clone();
5694        let run = run_assistant_loop(&Slow, &rt, &config, &mut messages, move |event| {
5695            run_emitter.on_assistant_event(&event);
5696        });
5697        tokio::pin!(run);
5698
5699        assert!(
5700            tokio::time::timeout(Duration::from_millis(100), &mut run)
5701                .await
5702                .is_err(),
5703            "the two-second generator must still be in flight"
5704        );
5705        {
5706            let events = sink.0.lock().unwrap();
5707            assert_eq!(events.len(), 1, "only the pre-await event should exist");
5708            assert_eq!(events[0]["type"], "inference_started");
5709            assert_eq!(events[0]["data"]["model"], "scripted");
5710            assert_eq!(events[0]["data"]["attempt"], 1);
5711            assert_eq!(events[0]["data"]["turn"], 1);
5712        }
5713
5714        let outcome = run.await;
5715        assert_eq!(outcome.status, "success");
5716        let types: Vec<String> = sink
5717            .0
5718            .lock()
5719            .unwrap()
5720            .iter()
5721            .map(|event| event["type"].as_str().unwrap().to_string())
5722            .collect();
5723        assert_eq!(types, ["inference_started", "model_served"]);
5724    }
5725
5726    #[tokio::test]
5727    async fn do_json_emits_inference_retry_between_started_and_served() {
5728        struct FailOnce {
5729            calls: AtomicUsize,
5730        }
5731
5732        #[async_trait]
5733        impl TurnGenerator for FailOnce {
5734            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
5735                if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
5736                    Err("transient transport".into())
5737                } else {
5738                    Ok(turn("done", json!([])))
5739                }
5740            }
5741
5742            async fn generate_assistant_observed(
5743                &self,
5744                req: GenerateRequest,
5745                retry_observer: &mut (dyn FnMut(car_inference::InferenceRetryProgress) + Send),
5746            ) -> Result<InferenceResult, AssistantGenerateError> {
5747                match self.generate(req.clone()).await {
5748                    Ok(result) => Ok(result),
5749                    Err(_) => {
5750                        retry_observer(car_inference::InferenceRetryProgress {
5751                            model: "scripted".into(),
5752                            attempt: 2,
5753                            reason: "transport",
5754                            backoff_ms: 25,
5755                        });
5756                        self.generate(req)
5757                            .await
5758                            .map_err(AssistantGenerateError::Other)
5759                    }
5760                }
5761            }
5762        }
5763
5764        let dir = tempfile::tempdir().unwrap();
5765        let rt = runtime_for(dir.path()).await;
5766        let sink = Arc::new(JsonProgress::default());
5767        let emitter = json_emitter(sink.clone());
5768        let mut messages = vec![sys("system"), usr("task")];
5769        let run_emitter = emitter.clone();
5770        let outcome = run_assistant_loop(
5771            &FailOnce {
5772                calls: AtomicUsize::new(0),
5773            },
5774            &rt,
5775            &cfg(),
5776            &mut messages,
5777            move |event| run_emitter.on_assistant_event(&event),
5778        )
5779        .await;
5780
5781        assert_eq!(outcome.status, "success");
5782        let events = sink.0.lock().unwrap();
5783        let types: Vec<&str> = events
5784            .iter()
5785            .map(|event| event["type"].as_str().unwrap())
5786            .collect();
5787        assert_eq!(
5788            types,
5789            ["inference_started", "inference_retry", "model_served"]
5790        );
5791        assert_eq!(events[1]["data"]["attempt"], 2);
5792        assert_eq!(events[1]["data"]["reason"], "transport");
5793        assert_eq!(events[1]["data"]["backoff_ms"], 25);
5794    }
5795
5796    /// What CAR does out of the box is a measured decision, not a literal that
5797    /// drifts (#813).
5798    ///
5799    /// The default used to be a bare `false` written at every production
5800    /// construction site, so "is it on?" could only be answered by grepping and
5801    /// hoping the sites agreed. It is now one constant, and this pins it to the
5802    /// value the A/B in [`VALUE_STORE_PREVIEWS_DEFAULT`]'s docs chose. Flipping
5803    /// it on without a new measurement fails here, which is the point: the
5804    /// numbers, not a preference, decide it.
5805    #[test]
5806    fn the_shipped_default_is_the_measured_one() {
5807        assert!(
5808            !VALUE_STORE_PREVIEWS_DEFAULT,
5809            "retained previews stay OFF because the 3-replicate car-bench-harness \
5810             A/B did not meet #813's fewer-calls criterion. Changing this needs a \
5811             new measurement, not an edit."
5812        );
5813    }
5814
5815    /// Pinning the constant's *value* is not enough — production has to read it
5816    /// (#813).
5817    ///
5818    /// [`the_shipped_default_is_the_measured_one`] fails if the constant flips,
5819    /// but it says nothing about who consults it. A change that hard-coded
5820    /// either arm at a construction site would fork the shared default with
5821    /// every other test still green. So this scans the crate's own production
5822    /// source for both literals.
5823    ///
5824    /// Everything from the first `#[cfg(test)]` onward is excluded: test
5825    /// scaffolding is entitled to pin either arm explicitly, and one fixture in
5826    /// this very module does.
5827    #[test]
5828    fn no_production_call_site_hard_codes_the_preview_default() {
5829        let crate_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
5830        for rel in [
5831            "src/assistant/agent_loop.rs",
5832            "src/assistant/chat.rs",
5833            "src/coder/discuss.rs",
5834            "src/mcp_assistant.rs",
5835        ] {
5836            let src = std::fs::read_to_string(crate_dir.join(rel))
5837                .unwrap_or_else(|e| panic!("reading {rel}: {e}"));
5838            let production = match src.find("\n#[cfg(test)]") {
5839                Some(cut) => &src[..cut],
5840                None => src.as_str(),
5841            };
5842            for literal in ["value_store_previews: false", "value_store_previews: true"] {
5843                assert!(
5844                    !production.contains(literal),
5845                    "{rel} hard-codes the preview arm in production code. The \
5846                     shipped default is VALUE_STORE_PREVIEWS_DEFAULT, chosen from a \
5847                     measured A/B; a literal here forks it silently."
5848                );
5849            }
5850        }
5851    }
5852
5853    /// The toggle must be genuinely inert when off (#813).
5854    ///
5855    /// This mattered before the A/B because a default with *any* observable
5856    /// effect would have spent the measurement's credibility before it was
5857    /// taken. It matters just as much after: the off arm is what the measured
5858    /// baseline in [`VALUE_STORE_PREVIEWS_DEFAULT`] was taken against, and a
5859    /// caller that opts back out is entitled to the old behavior exactly. So
5860    /// this still asserts the off path produces the destructive-truncation
5861    /// observation byte for byte.
5862    #[tokio::test]
5863    async fn the_off_arm_still_truncates_exactly_as_before() {
5864        assert!(
5865            !cfg().value_store_previews,
5866            "this fixture is the OFF arm — it pins the pre-#813 observation path, \
5867             not the shipped default (see VALUE_STORE_PREVIEWS_DEFAULT)"
5868        );
5869
5870        let dir = tempfile::tempdir().unwrap();
5871        let big = "x".repeat(OBSERVATION_CAP + 40_000);
5872        std::fs::write(dir.path().join("big.txt"), &big).unwrap();
5873        let rt = runtime_for(dir.path()).await;
5874
5875        let script = Script {
5876            turns: vec![
5877                turn(
5878                    "reading",
5879                    json!([{ "id": "c1", "name": "read_file", "arguments": { "path": "./big.txt" } }]),
5880                ),
5881                turn("done", json!([])),
5882            ],
5883            cursor: AtomicUsize::new(0),
5884        };
5885        let mut messages = vec![
5886            Message::System {
5887                content: "sys".into(),
5888            },
5889            Message::User {
5890                content: "read it".into(),
5891            },
5892        ];
5893        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
5894
5895        let observation = messages
5896            .iter()
5897            .find_map(|m| match m {
5898                Message::ToolResult { content, .. } => Some(content.clone()),
5899                _ => None,
5900            })
5901            .expect("a tool observation");
5902        assert!(
5903            observation.contains("…[truncated:"),
5904            "off path must still truncate destructively: {}",
5905            &observation[observation.len().saturating_sub(200)..]
5906        );
5907        assert!(
5908            !observation.contains("[full value retained"),
5909            "no handle may leak into the default transcript"
5910        );
5911        let receipt_result = outcome.tool_receipts[0].result.as_deref().unwrap();
5912        assert!(
5913            receipt_result.len() > OBSERVATION_CAP,
5914            "host evidence must be built from the complete result, not the capped transcript"
5915        );
5916        assert!(!receipt_result.contains("…[truncated:"));
5917    }
5918
5919    /// The property #813 is named for: with previews on, the data that used to
5920    /// be destroyed is still reachable *mid-run*.
5921    ///
5922    /// Proven end-to-end rather than by inspecting the store: turn 1 reads a
5923    /// file far larger than the cap, turn 2 passes the handle to `write_file`,
5924    /// and the bytes that never appeared in the transcript come back out on
5925    /// disk byte-identical. Under the old `cap()` this is impossible — rows
5926    /// 3-100, so to speak, were gone.
5927    #[tokio::test]
5928    async fn a_retained_value_survives_the_transcript_and_can_be_used_by_a_later_tool() {
5929        let dir = tempfile::tempdir().unwrap();
5930        // Distinct head and tail so a truncated copy could not pass.
5931        let big = format!(
5932            "HEAD-MARKER\n{}\nTAIL-MARKER",
5933            "z".repeat(OBSERVATION_CAP + 40_000)
5934        );
5935        std::fs::write(dir.path().join("big.txt"), &big).unwrap();
5936        let rt = runtime_for(dir.path()).await;
5937
5938        let script = Script {
5939            turns: vec![
5940                turn(
5941                    "reading",
5942                    json!([{ "id": "c1", "name": "read_file", "arguments": { "path": "./big.txt" } }]),
5943                ),
5944                turn(
5945                    "copying",
5946                    json!([{ "id": "c2", "name": "write_file",
5947                             "arguments": { "path": "./copy.txt", "content": "$r1.content" } }]),
5948                ),
5949                turn("done", json!([])),
5950            ],
5951            cursor: AtomicUsize::new(0),
5952        };
5953        let mut messages = vec![
5954            Message::System {
5955                content: "sys".into(),
5956            },
5957            Message::User {
5958                content: "copy it".into(),
5959            },
5960        ];
5961        let mut cfg = cfg();
5962        cfg.value_store_previews = true;
5963        cfg.max_turns = 8;
5964        run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
5965
5966        let observation = messages
5967            .iter()
5968            .find_map(|m| match m {
5969                Message::ToolResult { content, .. } => Some(content.clone()),
5970                _ => None,
5971            })
5972            .expect("a tool observation");
5973
5974        // The transcript carries shape, not the payload.
5975        assert!(
5976            observation.contains("content: text(len="),
5977            "the large field must announce its size and shape: {observation}"
5978        );
5979        assert!(
5980            observation.contains("[full value retained"),
5981            "the model must be told the value is reachable: {observation}"
5982        );
5983        assert!(
5984            observation.len() < 2_000,
5985            "preview must be bounded, got {} bytes",
5986            observation.len()
5987        );
5988        assert!(
5989            !observation.contains(&"z".repeat(1_000)),
5990            "the payload itself must not be in the transcript"
5991        );
5992
5993        // …and the elided bytes came back through the handle.
5994        //
5995        // Compared against read_file's OWN output rather than the file on disk:
5996        // that tool returns line-numbered content (`     1\tHEAD-MARKER`), so a
5997        // byte-identical round-trip against the source was never the property.
5998        // What matters is that everything past the truncation point survived.
5999        let copied = std::fs::read_to_string(dir.path().join("copy.txt"))
6000            .expect("the second tool must have run with the resolved value");
6001        assert!(
6002            copied.len() > OBSERVATION_CAP,
6003            "only {} bytes came back; the value was not retained in full",
6004            copied.len()
6005        );
6006        assert!(
6007            copied.contains("HEAD-MARKER"),
6008            "the head — the only part destructive truncation ever kept — is missing"
6009        );
6010        assert!(
6011            copied.contains("TAIL-MARKER"),
6012            "the TAIL is the part cap() always destroyed; recovering it is the \
6013             whole point of #813"
6014        );
6015        // And it never travelled through the transcript to get there.
6016        assert!(
6017            !observation.contains("TAIL-MARKER"),
6018            "the tail must have come from the store, not the context: {observation}"
6019        );
6020    }
6021
6022    #[tokio::test]
6023    async fn loop_runs_a_tool_then_finishes() {
6024        let dir = tempfile::tempdir().unwrap();
6025        let rt = runtime_for(dir.path()).await;
6026        // Turn 1: call calculate. Turn 2: finish with prose (no tool calls).
6027        let script = Script {
6028            turns: vec![
6029                turn(
6030                    "computing",
6031                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6032                ),
6033                turn("The answer is 42.", json!([])),
6034            ],
6035            cursor: AtomicUsize::new(0),
6036        };
6037        let mut messages = vec![
6038            Message::System {
6039                content: "sys".into(),
6040            },
6041            Message::User {
6042                content: "what is 6*7?".into(),
6043            },
6044        ];
6045        let mut events = Vec::new();
6046        let outcome =
6047            run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
6048
6049        assert_eq!(outcome.status, "success");
6050        assert_eq!(outcome.summary, "The answer is 42.");
6051        assert!(outcome.tools_called.contains(&"calculate".to_string()));
6052        let call_index = events
6053            .iter()
6054            .position(|event| matches!(event, AssistantEvent::ToolCall { name, .. } if name == "calculate"))
6055            .expect("tool call event");
6056        let result_index = events
6057            .iter()
6058            .position(|event| matches!(event, AssistantEvent::ToolResult { name, ok: true, .. } if name == "calculate"))
6059            .expect("tool result event");
6060        let answer_index = events
6061            .iter()
6062            .rposition(|event| matches!(event, AssistantEvent::Done { text } if text == "The answer is 42."))
6063            .expect("assistant answer event");
6064        assert!(
6065            call_index < result_index && result_index < answer_index,
6066            "the result must follow its call and precede later assistant text"
6067        );
6068        let receipt = outcome
6069            .tool_receipts
6070            .iter()
6071            .find(|receipt| receipt.tool == "calculate")
6072            .expect("calculator receipt");
6073        assert_eq!(receipt.call_id.as_deref(), Some("turn_1_call_1"));
6074        assert_eq!(receipt.sequence, Some(1));
6075        assert!(messages.iter().any(|message| matches!(
6076            message,
6077            Message::Assistant { tool_calls, .. }
6078                if tool_calls.first().and_then(|call| call.id.as_deref()) == Some("c1")
6079        )));
6080        assert!(messages.iter().any(|message| matches!(
6081            message,
6082            Message::ToolResult { tool_use_id, .. } if tool_use_id == "c1"
6083        )));
6084        assert!(
6085            receipt
6086                .result
6087                .as_deref()
6088                .is_some_and(|result| result.contains("42")),
6089            "the per-tool receipt must retain the uncapped result used for host evidence"
6090        );
6091    }
6092
6093    /// A turn receipt reports on the turn the person just watched. The loop
6094    /// seeds `tool_receipts` from the whole persisted thread for grounding, so
6095    /// without the `prior_receipts` split a long session's receipt frame
6096    /// published turns 1-4 (the wire projection keeps the FIRST 100 rows) and
6097    /// none of the calls that just ran.
6098    #[tokio::test]
6099    async fn a_second_invocation_reports_only_its_own_calls() {
6100        let dir = tempfile::tempdir().unwrap();
6101        let rt = runtime_for(dir.path()).await;
6102        let mut messages = vec![
6103            Message::System {
6104                content: "sys".into(),
6105            },
6106            Message::User {
6107                content: "what is 6*7?".into(),
6108            },
6109        ];
6110        let script = || Script {
6111            turns: vec![
6112                turn(
6113                    "computing",
6114                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6115                ),
6116                turn("The answer is 42.", json!([])),
6117            ],
6118            cursor: AtomicUsize::new(0),
6119        };
6120
6121        let first = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6122        assert_eq!(first.prior_receipts, 0, "nothing preceded the first turn");
6123        assert_eq!(first.run_receipts().len(), 1);
6124
6125        messages.push(Message::User {
6126            content: "again please".into(),
6127        });
6128        let second = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6129
6130        // Grounding still sees both turns.
6131        assert_eq!(
6132            second.tool_receipts.len(),
6133            2,
6134            "the full vec keeps the replayed seed for grounding"
6135        );
6136        assert_eq!(second.prior_receipts, 1);
6137        let run: Vec<_> = second
6138            .run_receipts()
6139            .iter()
6140            .map(|r| r.call_id.clone().unwrap())
6141            .collect();
6142        assert_eq!(
6143            run,
6144            vec!["turn_3_call_1".to_string()],
6145            "the receipt reports this invocation's call, not the first turn's"
6146        );
6147    }
6148
6149    /// `prior_assistant_turns` used to be a plain count of Assistant messages,
6150    /// which compaction makes non-monotone: it DROPS them mid-session, the next
6151    /// invocation recomputes a smaller count, and one session mints the same
6152    /// `turn_<n>_call_<k>` twice. That id is what a host correlates a streamed
6153    /// `tool_result` to its receipt by.
6154    #[tokio::test]
6155    async fn compaction_cannot_make_a_call_id_repeat_within_a_session() {
6156        let dir = tempfile::tempdir().unwrap();
6157        let rt = runtime_for(dir.path()).await;
6158        let script = || Script {
6159            turns: vec![
6160                turn(
6161                    "computing",
6162                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6163                ),
6164                turn("The answer is 42.", json!([])),
6165            ],
6166            cursor: AtomicUsize::new(0),
6167        };
6168        let mut messages = vec![sys("sys"), usr("what is 6*7?")];
6169
6170        // Enough turns that compaction has a droppable middle at all
6171        // (`HISTORY_MIN_TAIL` protects the most recent messages).
6172        let mut first_ids: Vec<String> = Vec::new();
6173        for round in 0..4 {
6174            if round > 0 {
6175                messages.push(usr("again please"));
6176            }
6177            let outcome = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6178            first_ids.extend(
6179                outcome
6180                    .run_receipts()
6181                    .iter()
6182                    .filter_map(|r| r.call_id.clone()),
6183            );
6184        }
6185        assert_eq!(first_ids.first().map(String::as_str), Some("turn_1_call_1"));
6186
6187        // Really compact, rather than asserting against a hand-built notice.
6188        let offset_before = transcript_turn_offset(&messages);
6189        let assistants_before = messages
6190            .iter()
6191            .filter(|m| matches!(m, Message::Assistant { .. }))
6192            .count();
6193        compact_history_measured(
6194            &mut messages,
6195            512,
6196            PromptMeasure {
6197                fixed_overhead: 0,
6198                reported: None,
6199            },
6200        );
6201        let assistants_after = messages
6202            .iter()
6203            .filter(|m| matches!(m, Message::Assistant { .. }))
6204            .count();
6205        // Positive control: without a real drop this test proves nothing.
6206        assert!(
6207            assistants_after < assistants_before,
6208            "compaction must actually have dropped an Assistant turn"
6209        );
6210        assert!(
6211            messages.iter().any(|m| matches!(
6212                m,
6213                Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX)
6214            )),
6215            "and left the notice the offset is recovered from"
6216        );
6217        // The invariant the ids rest on: the offset never moves DOWN. Counting
6218        // surviving Assistant messages alone, it just did.
6219        assert!(
6220            transcript_turn_offset(&messages) >= offset_before,
6221            "compaction moved the turn offset down: {} < {offset_before}",
6222            transcript_turn_offset(&messages)
6223        );
6224
6225        messages.push(usr("again please"));
6226        let second = run_assistant_loop(&script(), &rt, &cfg(), &mut messages, |_| {}).await;
6227        let second_ids: Vec<String> = second
6228            .run_receipts()
6229            .iter()
6230            .filter_map(|r| r.call_id.clone())
6231            .collect();
6232        assert!(!second_ids.is_empty(), "the second turn called a tool");
6233        for id in &second_ids {
6234            assert!(
6235                !first_ids.contains(id),
6236                "{id} was already minted before compaction: {first_ids:?}"
6237            );
6238        }
6239    }
6240
6241    /// The offset counts compacted-away messages, so it can only move up.
6242    #[test]
6243    fn transcript_turn_offset_includes_compacted_away_turns() {
6244        let bare = vec![
6245            sys("sys"),
6246            usr("task"),
6247            Message::Assistant {
6248                content: "a".into(),
6249                tool_calls: vec![],
6250                thinking: vec![],
6251                model_id: None,
6252                local_last_resort: false,
6253            },
6254        ];
6255        assert_eq!(transcript_turn_offset(&bare), 1);
6256
6257        let mut compacted = bare.clone();
6258        compacted.insert(
6259            2,
6260            Message::System {
6261                content: format_compaction_notice(6, 400, CompactionRecovery::default()),
6262            },
6263        );
6264        eprintln!(
6265            "DEBUG notice={:?} parsed={:?}",
6266            match &compacted[2] {
6267                Message::System { content } => content.clone(),
6268                _ => String::new(),
6269            },
6270            parse_compaction_notice(&compacted[2])
6271        );
6272        assert_eq!(
6273            transcript_turn_offset(&compacted),
6274            7,
6275            "six dropped messages plus the one surviving Assistant turn"
6276        );
6277    }
6278
6279    struct FailAfterDispatch;
6280
6281    #[async_trait]
6282    impl ApprovalGate for FailAfterDispatch {
6283        async fn request(&self, _tool: &str, _params: &Value) -> ApprovalDecision {
6284            ApprovalDecision::Approved
6285        }
6286
6287        async fn after_dispatch(
6288            &self,
6289            _call_id: &str,
6290            _tool: &str,
6291            _params: &Value,
6292            _ok: bool,
6293            _receipt: &Value,
6294        ) -> Result<(), String> {
6295            Err("receipt store unavailable".into())
6296        }
6297    }
6298
6299    #[tokio::test]
6300    async fn terminal_receipt_error_after_dispatch_emits_a_failed_tool_result() {
6301        let dir = tempfile::tempdir().unwrap();
6302        let rt = runtime_for(dir.path()).await;
6303        let script = Script {
6304            turns: vec![turn(
6305                "computing",
6306                json!([{ "id": "repeated", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6307            )],
6308            cursor: AtomicUsize::new(0),
6309        };
6310        let mut messages = vec![sys("sys"), usr("what is 6*7?")];
6311        let mut config = cfg();
6312        config.gated_tools = vec!["calculate".into()];
6313        let cancel = AtomicBool::new(false);
6314        let mut events = Vec::new();
6315        let outcome = run_assistant_loop_cancellable(
6316            &script,
6317            &rt,
6318            &config,
6319            &mut messages,
6320            &cancel,
6321            Some(&FailAfterDispatch),
6322            None,
6323            |event| events.push(event),
6324        )
6325        .await;
6326
6327        assert_eq!(outcome.status, "error");
6328        assert_eq!(outcome.tool_receipts.len(), 1);
6329        assert!(!outcome.tool_receipts[0].ok);
6330        let result_index = events
6331            .iter()
6332            .position(|event| matches!(event, AssistantEvent::ToolResult { ok: false, .. }))
6333            .expect("failed tool result");
6334        let error_index = events
6335            .iter()
6336            .position(|event| matches!(event, AssistantEvent::Error(_)))
6337            .expect("terminal error");
6338        assert!(result_index < error_index);
6339    }
6340
6341    #[tokio::test]
6342    async fn cancellation_after_a_request_emits_its_failed_result_row() {
6343        let dir = tempfile::tempdir().unwrap();
6344        let rt = runtime_for(dir.path()).await;
6345        let script = Script {
6346            turns: vec![turn(
6347                "computing",
6348                json!([{ "id": "repeated", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6349            )],
6350            cursor: AtomicUsize::new(0),
6351        };
6352        let mut messages = vec![sys("sys"), usr("what is 6*7?")];
6353        let cancel = AtomicBool::new(false);
6354        let mut events = Vec::new();
6355        let outcome = run_assistant_loop_cancellable(
6356            &script,
6357            &rt,
6358            &cfg(),
6359            &mut messages,
6360            &cancel,
6361            None,
6362            None,
6363            |event| {
6364                if matches!(event, AssistantEvent::ToolCall { .. }) {
6365                    cancel.store(true, Ordering::Relaxed);
6366                }
6367                events.push(event);
6368            },
6369        )
6370        .await;
6371
6372        assert_eq!(outcome.status, "cancelled");
6373        assert_eq!(outcome.tool_receipts.len(), 1);
6374        assert!(!outcome.tool_receipts[0].ok);
6375        let call = events
6376            .iter()
6377            .find_map(|event| match event {
6378                AssistantEvent::ToolCall {
6379                    call_id, sequence, ..
6380                } => Some((call_id, sequence)),
6381                _ => None,
6382            })
6383            .expect("request row");
6384        let result = events
6385            .iter()
6386            .find_map(|event| match event {
6387                AssistantEvent::ToolResult {
6388                    call_id,
6389                    sequence,
6390                    ok,
6391                    ..
6392                } => Some((call_id, sequence, ok)),
6393                _ => None,
6394            })
6395            .expect("cancel result row");
6396        assert_eq!(result.0, call.0);
6397        assert_eq!(result.1, call.1);
6398        assert!(!result.2);
6399    }
6400
6401    #[tokio::test]
6402    async fn loop_replays_managed_responses_continuity_on_second_turn() {
6403        let dir = tempfile::tempdir().unwrap();
6404        let rt = runtime_for(dir.path()).await;
6405        let reasoning = json!({
6406            "type": "reasoning",
6407            "id": "rs_agent",
6408            "status": "completed",
6409            "summary": [{"type": "summary_text", "text": "safe"}],
6410            "encrypted_content": "opaque-agent",
6411        });
6412        let mut first = turn(
6413            "checking",
6414            json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6415        );
6416        first.provider_output_items = vec![reasoning.clone()];
6417        let seen = Arc::new(StdMutex::new(Vec::new()));
6418        let script = CapturingScript {
6419            turns: vec![first, turn("done", json!([]))],
6420            cursor: AtomicUsize::new(0),
6421            seen: seen.clone(),
6422        };
6423        let mut messages = vec![
6424            Message::System {
6425                content: "sys".into(),
6426            },
6427            Message::User {
6428                content: "calculate".into(),
6429            },
6430        ];
6431
6432        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_e| {}).await;
6433
6434        assert_eq!(outcome.status, "success");
6435        assert!(
6436            !outcome.summary.contains("opaque-agent"),
6437            "opaque continuity must never become user-visible text"
6438        );
6439        let seen = seen.lock().unwrap();
6440        let second = seen[1].messages.as_ref().expect("second-turn history");
6441        assert!(matches!(
6442            &second[2],
6443            Message::ProviderOutputItems { protocol, items }
6444                if protocol == car_inference::protocol::OPENAI_RESPONSES_PROTOCOL
6445                    && items == &vec![reasoning]
6446        ));
6447        assert!(matches!(
6448            &second[3],
6449            Message::Assistant { content, .. } if content == "checking"
6450        ));
6451        assert!(matches!(&second[4], Message::ToolResult { .. }));
6452    }
6453
6454    #[tokio::test]
6455    async fn loop_injects_proactive_memory_before_generation() {
6456        let dir = tempfile::tempdir().unwrap();
6457        let rt = runtime_for(dir.path()).await;
6458        let memory = Arc::new(crate::assistant::memory::MemoryTools::open(
6459            dir.path().join("assistant-memory.json"),
6460        ));
6461        memory
6462            .execute(
6463                "remember",
6464                &json!({
6465                    "subject": "phoenix task requirement",
6466                    "body": "Requirement: for phoenix task work, run pytest before finishing."
6467                }),
6468            )
6469            .await
6470            .unwrap();
6471        let seen = Arc::new(StdMutex::new(Vec::new()));
6472        let generator = CapturingGenerator { seen: seen.clone() };
6473        let mut cfg = cfg();
6474        cfg.proactive_memory = Some(memory);
6475        let mut messages = vec![
6476            Message::System {
6477                content: "sys".into(),
6478            },
6479            Message::User {
6480                content: "finish the phoenix task".into(),
6481            },
6482        ];
6483
6484        let outcome = run_assistant_loop(&generator, &rt, &cfg, &mut messages, |_| {}).await;
6485
6486        assert_eq!(outcome.status, "success");
6487        // Scope the std MutexGuard so it drops before the `.await` below
6488        // (clippy::await_holding_lock).
6489        {
6490            let captured = seen.lock().unwrap();
6491            let context = captured[0].context.as_deref().unwrap_or("");
6492            assert!(
6493                context.contains("## Proactive Memory"),
6494                "request context should carry proactive memory: {context}"
6495            );
6496            assert!(
6497                context.contains("run pytest before finishing"),
6498                "selected memory should be injected: {context}"
6499            );
6500        }
6501        let log = rt.log.lock().await;
6502        assert!(log
6503            .events()
6504            .iter()
6505            .any(|e| e.kind == car_eventlog::EventKind::ProactiveMemoryMaintained));
6506        assert!(log.events().iter().any(|e| {
6507            e.kind == car_eventlog::EventKind::ProactiveMemoryIntervention
6508                && e.data.get("decision") == Some(&json!("inject"))
6509        }));
6510    }
6511
6512    #[tokio::test]
6513    async fn loop_learns_the_call_that_recovered_a_failed_tool() {
6514        // The whole claim of `tool_memory`, driven through the real loop: a
6515        // tool fails, the next call to the SAME tool succeeds, and what
6516        // succeeded is what a later run gets to see.
6517        let dir = tempfile::tempdir().unwrap();
6518        let rt = runtime_for(dir.path()).await;
6519        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6520            dir.path().join("repairs.json"),
6521        ));
6522        let script = Script {
6523            turns: vec![
6524                turn(
6525                    "trying",
6526                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6527                ),
6528                turn(
6529                    "retrying",
6530                    json!([{ "id": "c2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6531                ),
6532                turn("done", json!([])),
6533            ],
6534            cursor: AtomicUsize::new(0),
6535        };
6536        let mut cfg = cfg();
6537        cfg.tool_memory = Some(memory.clone());
6538        let mut messages = vec![
6539            Message::System {
6540                content: "sys".into(),
6541            },
6542            Message::User {
6543                content: "compute six times seven".into(),
6544            },
6545        ];
6546
6547        let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6548
6549        assert_eq!(outcome.status, "success");
6550        assert_eq!(
6551            memory.learned_count(),
6552            1,
6553            "the recovering call should have been learned"
6554        );
6555    }
6556
6557    #[tokio::test]
6558    async fn a_learned_repair_reaches_the_next_run_that_hits_the_same_failure() {
6559        // The end-to-end claim: run one, learn; run two, the lead is in the
6560        // request context BEFORE the model's next turn. Two independent loops
6561        // over one store, which is exactly the cross-session shape.
6562        let dir = tempfile::tempdir().unwrap();
6563        let rt = runtime_for(dir.path()).await;
6564        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6565            dir.path().join("repairs.json"),
6566        ));
6567        let mut cfg = cfg();
6568        cfg.tool_memory = Some(memory.clone());
6569
6570        let learning = Script {
6571            turns: vec![
6572                turn(
6573                    "trying",
6574                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6575                ),
6576                turn(
6577                    "retrying",
6578                    json!([{ "id": "c2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6579                ),
6580                turn("done", json!([])),
6581            ],
6582            cursor: AtomicUsize::new(0),
6583        };
6584        let mut messages = vec![
6585            Message::System {
6586                content: "sys".into(),
6587            },
6588            Message::User {
6589                content: "compute six times seven".into(),
6590            },
6591        ];
6592        run_assistant_loop(&learning, &rt, &cfg, &mut messages, |_| {}).await;
6593        assert_eq!(memory.learned_count(), 1, "run one must learn something");
6594
6595        // Run two: same failure, fresh transcript, fresh loop.
6596        let seen = Arc::new(StdMutex::new(Vec::new()));
6597        let second = CapturingScript {
6598            turns: vec![
6599                turn(
6600                    "trying",
6601                    json!([{ "id": "d1", "name": "calculate", "arguments": { "expression": "9 ** ** 9" } }]),
6602                ),
6603                turn("done", json!([])),
6604            ],
6605            cursor: AtomicUsize::new(0),
6606            seen: seen.clone(),
6607        };
6608        let mut messages = vec![
6609            Message::System {
6610                content: "sys".into(),
6611            },
6612            Message::User {
6613                content: "compute nine times nine".into(),
6614            },
6615        ];
6616        run_assistant_loop(&second, &rt, &cfg, &mut messages, |_| {}).await;
6617
6618        let captured = seen.lock().unwrap();
6619        let first_context = captured[0].context.as_deref().unwrap_or("");
6620        assert!(
6621            !first_context.contains("## Learned Repairs"),
6622            "nothing has failed yet on this run: {first_context}"
6623        );
6624        let after_failure = captured[1].context.as_deref().unwrap_or("");
6625        assert!(
6626            after_failure.contains("## Learned Repairs"),
6627            "the turn after the failure should carry the lead: {after_failure}"
6628        );
6629        assert!(
6630            after_failure.contains("6*7"),
6631            "the lead should be the call that actually recovered: {after_failure}"
6632        );
6633    }
6634
6635    #[tokio::test]
6636    async fn an_unrelated_later_success_is_not_credited_as_a_repair() {
6637        // The pairing heuristic's guard rail: a success on a DIFFERENT tool
6638        // never closes an open failure, so the loop cannot learn a lead that
6639        // had nothing to do with the failure.
6640        let dir = tempfile::tempdir().unwrap();
6641        let rt = runtime_for(dir.path()).await;
6642        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6643            dir.path().join("repairs.json"),
6644        ));
6645        let script = Script {
6646            turns: vec![
6647                turn(
6648                    "trying",
6649                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6650                ),
6651                turn(
6652                    "moving on",
6653                    json!([{ "id": "c2", "name": "write_file", "arguments": { "path": "note.txt", "content": "hi" } }]),
6654                ),
6655                turn("done", json!([])),
6656            ],
6657            cursor: AtomicUsize::new(0),
6658        };
6659        let mut cfg = cfg();
6660        cfg.tool_memory = Some(memory.clone());
6661        let mut messages = vec![
6662            Message::System {
6663                content: "sys".into(),
6664            },
6665            Message::User {
6666                content: "do two things".into(),
6667            },
6668        ];
6669
6670        run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6671
6672        assert_eq!(
6673            memory.learned_count(),
6674            0,
6675            "a different tool succeeding is not a repair for the one that failed"
6676        );
6677    }
6678
6679    /// Drive the loop over a scripted fail→(gap)→succeed sequence and report
6680    /// what was learned. `gap` extra turns sit between the failure and the
6681    /// success so the recovery window can be probed at its boundary.
6682    async fn learn_over_gap(gap: usize, recover_with: &str) -> usize {
6683        let dir = tempfile::tempdir().unwrap();
6684        let rt = runtime_for(dir.path()).await;
6685        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6686            dir.path().join("repairs.json"),
6687        ));
6688        let mut turns = vec![turn(
6689            "trying",
6690            json!([{ "id": "c0", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6691        )];
6692        // Filler turns on a DIFFERENT tool, so only the clock advances.
6693        for i in 0..gap {
6694            turns.push(turn(
6695                "thinking",
6696                json!([{ "id": format!("g{i}"), "name": "todo_write",
6697                         "arguments": { "items": [{"task": format!("step {i}"), "status": "pending"}] } }]),
6698            ));
6699        }
6700        turns.push(turn(
6701            "retrying",
6702            json!([{ "id": "cN", "name": "calculate", "arguments": { "expression": recover_with } }]),
6703        ));
6704        turns.push(turn("done", json!([])));
6705        let script = Script {
6706            turns,
6707            cursor: AtomicUsize::new(0),
6708        };
6709        let mut cfg = cfg();
6710        cfg.max_turns = 12;
6711        cfg.tool_memory = Some(memory.clone());
6712        let mut messages = vec![
6713            Message::System {
6714                content: "sys".into(),
6715            },
6716            Message::User {
6717                content: "compute six times seven".into(),
6718            },
6719        ];
6720        run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6721        memory.learned_count()
6722    }
6723
6724    #[tokio::test]
6725    async fn a_recovery_inside_the_window_is_learned_and_one_outside_it_is_not() {
6726        // The window boundary itself. A `>` / `>=` slip here would silently
6727        // credit unrelated later successes, which is the whole failure mode
6728        // RECOVERY_WINDOW_TURNS exists to bound.
6729        assert_eq!(learn_over_gap(0, "6*7").await, 1, "next turn is a recovery");
6730        assert_eq!(
6731            learn_over_gap(RECOVERY_WINDOW_TURNS as usize - 1, "6*7").await,
6732            1,
6733            "the last turn inside the window still counts"
6734        );
6735        assert_eq!(
6736            learn_over_gap(RECOVERY_WINDOW_TURNS as usize + 2, "6*7").await,
6737            0,
6738            "well past the window is not a repair"
6739        );
6740    }
6741
6742    #[tokio::test]
6743    async fn an_identical_retry_that_happens_to_work_is_not_a_repair() {
6744        // The fix for the review's central finding: a success whose arguments
6745        // match the ones that failed changed nothing, so it teaches nothing.
6746        // Without this guard every routine success on a tool with an open
6747        // failure harvested a bogus lead.
6748        assert_eq!(
6749            learn_over_gap(0, "6 ** ** 7").await,
6750            0,
6751            "same arguments succeeding is a transient, not a repair"
6752        );
6753    }
6754
6755    #[tokio::test]
6756    async fn a_success_on_a_different_tool_never_closes_another_tools_failure() {
6757        // `take_recovery` is keyed by tool name before the differs-args check.
6758        // Drop that key and a routine `todo_write` success becomes the durable
6759        // "repair" for an open `calculate` failure — a lead filed under the
6760        // wrong tool entirely.
6761        let dir = tempfile::tempdir().unwrap();
6762        let rt = runtime_for(dir.path()).await;
6763        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6764            dir.path().join("repairs.json"),
6765        ));
6766        let script = Script {
6767            turns: vec![
6768                turn(
6769                    "trying",
6770                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6771                ),
6772                turn(
6773                    "different tool",
6774                    json!([{ "id": "c2", "name": "todo_write",
6775                             "arguments": { "items": [{"task": "unrelated", "status": "pending"}] } }]),
6776                ),
6777                turn("done", json!([])),
6778            ],
6779            cursor: AtomicUsize::new(0),
6780        };
6781        let mut cfg = cfg();
6782        cfg.tool_memory = Some(memory.clone());
6783        let mut messages = vec![
6784            Message::System {
6785                content: "sys".into(),
6786            },
6787            Message::User {
6788                content: "do things".into(),
6789            },
6790        ];
6791        run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
6792        assert_eq!(
6793            memory.learned_count(),
6794            0,
6795            "a different tool's success is not a repair for this one"
6796        );
6797    }
6798
6799    #[tokio::test]
6800    async fn one_stale_lead_costs_exactly_one_failure_however_many_retries() {
6801        // The `penalized` guard. Without it, four retries behind a single
6802        // offered lead would push it from healthy to degraded in one run
6803        // (fail > success + 2), silently retiring a lead that may be fine.
6804        let dir = tempfile::tempdir().unwrap();
6805        let rt = runtime_for(dir.path()).await;
6806        let store = dir.path().join("repairs.json");
6807        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6808            store.clone(),
6809        ));
6810        let mut cfg = cfg();
6811        cfg.max_turns = 12;
6812        cfg.tool_memory = Some(memory.clone());
6813
6814        // Run one: learn a lead for calculate's failure signature.
6815        let learn = Script {
6816            turns: vec![
6817                turn(
6818                    "trying",
6819                    json!([{ "id": "a1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6820                ),
6821                turn(
6822                    "retrying",
6823                    json!([{ "id": "a2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6824                ),
6825                turn("done", json!([])),
6826            ],
6827            cursor: AtomicUsize::new(0),
6828        };
6829        let mut messages = vec![
6830            Message::System {
6831                content: "sys".into(),
6832            },
6833            Message::User {
6834                content: "compute".into(),
6835            },
6836        ];
6837        run_assistant_loop(&learn, &rt, &cfg, &mut messages, |_| {}).await;
6838        assert_eq!(memory.learned_count(), 1);
6839
6840        // Run two: the lead is offered, then the same signature fails four more
6841        // times. Exactly one failure should be recorded, so the lead survives.
6842        let mut turns = Vec::new();
6843        for i in 0..5 {
6844            turns.push(turn(
6845                "failing",
6846                json!([{ "id": format!("b{i}"), "name": "calculate",
6847                         "arguments": { "expression": format!("{i} ** ** {i}") } }]),
6848            ));
6849        }
6850        turns.push(turn("giving up", json!([])));
6851        let retry_storm = Script {
6852            turns,
6853            cursor: AtomicUsize::new(0),
6854        };
6855        let mut messages = vec![
6856            Message::System {
6857                content: "sys".into(),
6858            },
6859            Message::User {
6860                content: "compute".into(),
6861            },
6862        ];
6863        run_assistant_loop(&retry_storm, &rt, &cfg, &mut messages, |_| {}).await;
6864
6865        let sig = crate::assistant::tool_memory::FailureSignature::from_failure(
6866            "calculate",
6867            "[FAILED] bad expression",
6868        );
6869        assert!(
6870            memory.recall(&sig).is_some(),
6871            "one offered lead must cost one failure, not one per retry — \
6872             five penalties would have degraded it"
6873        );
6874    }
6875
6876    #[tokio::test]
6877    async fn penalty_markers_do_not_leak_between_runs() {
6878        // `offered` / `penalized` live on the per-run OpenFailures. If they were
6879        // hoisted to the store, a fresh run's FIRST failure would count as
6880        // "offered and failed again" and penalize a lead that was never served.
6881        let dir = tempfile::tempdir().unwrap();
6882        let rt = runtime_for(dir.path()).await;
6883        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6884            dir.path().join("repairs.json"),
6885        ));
6886        let mut cfg = cfg();
6887        cfg.tool_memory = Some(memory.clone());
6888
6889        let learn = Script {
6890            turns: vec![
6891                turn(
6892                    "trying",
6893                    json!([{ "id": "a1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6894                ),
6895                turn(
6896                    "retrying",
6897                    json!([{ "id": "a2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6898                ),
6899                turn("done", json!([])),
6900            ],
6901            cursor: AtomicUsize::new(0),
6902        };
6903        let mut messages = vec![
6904            Message::System {
6905                content: "sys".into(),
6906            },
6907            Message::User {
6908                content: "compute".into(),
6909            },
6910        ];
6911        run_assistant_loop(&learn, &rt, &cfg, &mut messages, |_| {}).await;
6912
6913        // Three more runs, each with a single unrepaired failure. A leaked
6914        // marker would penalize on every one and degrade the lead by run four.
6915        for round in 0..3 {
6916            let single = Script {
6917                turns: vec![
6918                    turn(
6919                        "trying",
6920                        json!([{ "id": format!("r{round}"), "name": "calculate",
6921                                 "arguments": { "expression": format!("{round} ** ** {round}") } }]),
6922                    ),
6923                    turn("done", json!([])),
6924                ],
6925                cursor: AtomicUsize::new(0),
6926            };
6927            let mut messages = vec![
6928                Message::System {
6929                    content: "sys".into(),
6930                },
6931                Message::User {
6932                    content: "compute".into(),
6933                },
6934            ];
6935            run_assistant_loop(&single, &rt, &cfg, &mut messages, |_| {}).await;
6936        }
6937
6938        let sig = crate::assistant::tool_memory::FailureSignature::from_failure(
6939            "calculate",
6940            "[FAILED] bad expression",
6941        );
6942        assert!(
6943            memory.recall(&sig).is_some(),
6944            "a first failure in a fresh run is not evidence against a lead"
6945        );
6946    }
6947
6948    #[tokio::test]
6949    async fn the_none_path_writes_nothing_to_disk() {
6950        // `learning_is_off_unless_the_surface_opted_in` asserts no recall block.
6951        // This asserts the other half of "off": no file appears either.
6952        let dir = tempfile::tempdir().unwrap();
6953        let rt = runtime_for(dir.path()).await;
6954        let store = dir.path().join("repairs.json");
6955        let script = Script {
6956            turns: vec![
6957                turn(
6958                    "trying",
6959                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
6960                ),
6961                turn(
6962                    "retrying",
6963                    json!([{ "id": "c2", "name": "calculate", "arguments": { "expression": "6*7" } }]),
6964                ),
6965                turn("done", json!([])),
6966            ],
6967            cursor: AtomicUsize::new(0),
6968        };
6969        let mut messages = vec![
6970            Message::System {
6971                content: "sys".into(),
6972            },
6973            Message::User {
6974                content: "compute".into(),
6975            },
6976        ];
6977        run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
6978        assert!(
6979            !store.exists(),
6980            "a surface that did not opt in must leave no store behind"
6981        );
6982    }
6983
6984    #[test]
6985    fn a_delegate_child_inherits_the_learning_store() {
6986        // `delegate_child_config` builds the child with `..parent.clone()`.
6987        // Replacing that with an explicit literal would compile clean and
6988        // silently stop every sub-agent from learning, so pin it.
6989        let dir = tempfile::tempdir().unwrap();
6990        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
6991            dir.path().join("repairs.json"),
6992        ));
6993        let mut parent = cfg();
6994        parent.tools = vec![json!({
6995            "name": "calculate",
6996            "description": "d",
6997            "input_schema": {"type": "object"}
6998        })];
6999        parent.tool_memory = Some(memory.clone());
7000        let child = delegate_child_config(
7001            &parent,
7002            &DelegateRequest {
7003                goal: "sub".into(),
7004                tools: None,
7005                max_turns: 2,
7006            },
7007        )
7008        .expect("child config");
7009        let inherited = child.tool_memory.expect("child inherits the store");
7010        assert!(
7011            Arc::ptr_eq(&inherited, &memory),
7012            "the child must learn into the SAME store, not a fresh one"
7013        );
7014    }
7015
7016    #[tokio::test]
7017    async fn a_secret_in_a_recovering_call_never_reaches_the_store_through_the_loop() {
7018        // The store-level redaction test never sees a loop-produced approach.
7019        // This drives a credential-shaped token through the real loop.
7020        let dir = tempfile::tempdir().unwrap();
7021        let rt = runtime_for(dir.path()).await;
7022        let store = dir.path().join("repairs.json");
7023        let memory = Arc::new(crate::assistant::tool_memory::ToolMemory::open(
7024            store.clone(),
7025        ));
7026        let script = Script {
7027            turns: vec![
7028                turn(
7029                    "trying",
7030                    json!([{ "id": "c1", "name": "write_file",
7031                             "arguments": { "path": "x.txt", "content": "nope", "bogus": true } }]),
7032                ),
7033                turn(
7034                    "retrying",
7035                    json!([{ "id": "c2", "name": "write_file",
7036                             "arguments": { "path": "x.txt",
7037                                            "content": "token ghp_ABCDEFGHIJKLMNOPQRST" } }]),
7038                ),
7039                turn("done", json!([])),
7040            ],
7041            cursor: AtomicUsize::new(0),
7042        };
7043        let mut cfg = cfg();
7044        cfg.tool_memory = Some(memory.clone());
7045        let mut messages = vec![
7046            Message::System {
7047                content: "sys".into(),
7048            },
7049            Message::User {
7050                content: "write the file".into(),
7051            },
7052        ];
7053        run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
7054        if store.exists() {
7055            let on_disk = std::fs::read_to_string(&store).unwrap();
7056            assert!(
7057                !on_disk.contains("ghp_ABCDEFGHIJKLMNOPQRST"),
7058                "a credential must not survive into the durable store: {on_disk}"
7059            );
7060        }
7061    }
7062
7063    #[tokio::test]
7064    async fn learning_is_off_unless_the_surface_opted_in() {
7065        // `tool_memory: None` must leave the loop byte-identical: no context
7066        // block, and nothing written anywhere.
7067        let dir = tempfile::tempdir().unwrap();
7068        let rt = runtime_for(dir.path()).await;
7069        let seen = Arc::new(StdMutex::new(Vec::new()));
7070        let script = CapturingScript {
7071            turns: vec![
7072                turn(
7073                    "trying",
7074                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6 ** ** 7" } }]),
7075                ),
7076                turn("done", json!([])),
7077            ],
7078            cursor: AtomicUsize::new(0),
7079            seen: seen.clone(),
7080        };
7081        let mut messages = vec![
7082            Message::System {
7083                content: "sys".into(),
7084            },
7085            Message::User {
7086                content: "compute".into(),
7087            },
7088        ];
7089
7090        run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
7091
7092        let captured = seen.lock().unwrap();
7093        assert!(
7094            captured.iter().all(|req| !req
7095                .context
7096                .as_deref()
7097                .unwrap_or("")
7098                .contains("Learned Repairs")),
7099            "no surface opted in, so nothing should be recalled"
7100        );
7101    }
7102
7103    #[tokio::test]
7104    async fn loop_journals_turn_completed_at_empty_tool_calls_terminal() {
7105        // The default (ungrounded) path's completion decision must be a durable,
7106        // queryable event. Drives the loop to the empty-tool-calls terminal and
7107        // asserts the journaled TurnCompleted — this would FAIL if the emit at
7108        // agent_loop.rs were removed (the flagship path previously had no such
7109        // driven-loop assertion, unlike the coder path).
7110        let dir = tempfile::tempdir().unwrap();
7111        let rt = runtime_for(dir.path()).await;
7112        let script = Script {
7113            turns: vec![
7114                turn(
7115                    "computing",
7116                    json!([{ "id": "c1", "name": "calculate", "arguments": { "expression": "6*7" } }]),
7117                ),
7118                turn("The answer is 42.", json!([])),
7119            ],
7120            cursor: AtomicUsize::new(0),
7121        };
7122        let mut messages = vec![
7123            Message::System {
7124                content: "sys".into(),
7125            },
7126            Message::User {
7127                content: "what is 6*7?".into(),
7128            },
7129        ];
7130        let mut events = Vec::new();
7131        let outcome =
7132            run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
7133        assert_eq!(outcome.status, "success");
7134        // Model provenance is threaded out to the outcome (leftover A plumbing).
7135        assert_eq!(outcome.model_used, "scripted");
7136
7137        let log = rt.log.lock().await;
7138        let tc = log
7139            .events()
7140            .iter()
7141            .find(|e| e.kind == car_eventlog::EventKind::TurnCompleted)
7142            .expect("empty-tool-calls terminal must journal a TurnCompleted");
7143        assert_eq!(
7144            tc.data.get("decision"),
7145            Some(&serde_json::json!("empty_tool_calls"))
7146        );
7147        assert_eq!(tc.data.get("turns"), Some(&serde_json::json!(2)));
7148        assert_eq!(
7149            tc.data.get("model_id"),
7150            Some(&serde_json::json!("scripted"))
7151        );
7152        // "scripted" has no provider prefix in the allow-list → unknown tier.
7153        assert_eq!(
7154            tc.data.get("model_tier"),
7155            Some(&serde_json::json!("unknown"))
7156        );
7157    }
7158
7159    #[tokio::test]
7160    async fn loop_journals_turn_completed_at_max_turns_terminal() {
7161        // The model never finishes — it calls a tool every turn until the cap is
7162        // hit. The max_turns terminal must journal a TurnCompleted so a run that
7163        // "stopped after N turns without finishing" is distinguishable from a
7164        // clean finish in the audit trail.
7165        let dir = tempfile::tempdir().unwrap();
7166        let rt = runtime_for(dir.path()).await;
7167        let tool_turn = || {
7168            turn(
7169                "still going",
7170                json!([{ "id": "c", "name": "calculate", "arguments": { "expression": "1+1" } }]),
7171            )
7172        };
7173        let script = Script {
7174            turns: (0..10).map(|_| tool_turn()).collect(),
7175            cursor: AtomicUsize::new(0),
7176        };
7177        let mut messages = vec![
7178            Message::System {
7179                content: "sys".into(),
7180            },
7181            Message::User {
7182                content: "loop".into(),
7183            },
7184        ];
7185        // Cap below the stall-break threshold so max_turns is the terminal.
7186        let cfg = AssistantConfig {
7187            max_turns: 3,
7188            ..cfg()
7189        };
7190        let mut events = Vec::new();
7191        let outcome =
7192            run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
7193        assert_eq!(outcome.status, "max_turns");
7194
7195        let log = rt.log.lock().await;
7196        let tc = log
7197            .events()
7198            .iter()
7199            .find(|e| {
7200                e.kind == car_eventlog::EventKind::TurnCompleted
7201                    && e.data.get("decision") == Some(&serde_json::json!("max_turns"))
7202            })
7203            .expect("max_turns terminal must journal a TurnCompleted");
7204        assert_eq!(tc.data.get("turns"), Some(&serde_json::json!(3)));
7205    }
7206
7207    #[test]
7208    fn summary_claim_grounding_requires_matching_receipts() {
7209        let ungrounded = ungrounded_summary_claims("I ran the tests and they passed.", &[]);
7210        assert_eq!(ungrounded, vec!["tests were run/passed"]);
7211
7212        let grounded = ungrounded_summary_claims(
7213            "I ran the tests and they passed.",
7214            &[AssistantToolReceipt {
7215                tool: "shell".into(),
7216                call_id: Some("s1".into()),
7217                sequence: None,
7218                ok: true,
7219                params: json!({ "command": "cargo test -q" }),
7220                result: None,
7221                via: None,
7222            }],
7223        );
7224        assert!(grounded.is_empty(), "{grounded:?}");
7225
7226        let failed = ungrounded_summary_claims(
7227            "I ran the tests and they passed.",
7228            &[AssistantToolReceipt {
7229                tool: "shell".into(),
7230                call_id: Some("s1".into()),
7231                sequence: None,
7232                ok: false,
7233                params: json!({ "command": "cargo test -q" }),
7234                result: None,
7235                via: None,
7236            }],
7237        );
7238        assert_eq!(failed, vec!["tests were run/passed"]);
7239    }
7240
7241    #[test]
7242    fn summary_claim_grounding_catches_verification_and_check_claims() {
7243        assert_eq!(
7244            ungrounded_summary_claims("Verified with cargo test.", &[]),
7245            vec!["tests were run/passed"]
7246        );
7247        assert_eq!(
7248            ungrounded_summary_claims("cargo check passed.", &[]),
7249            vec!["build succeeded", "checks were run/passed"]
7250        );
7251        assert_eq!(
7252            ungrounded_summary_claims("All checks are green.", &[]),
7253            vec!["checks were run/passed"]
7254        );
7255
7256        let cargo_check = [AssistantToolReceipt {
7257            tool: "shell".into(),
7258            call_id: Some("s1".into()),
7259            sequence: None,
7260            ok: true,
7261            params: json!({ "command": "cargo check -p car-server-core" }),
7262            result: None,
7263            via: None,
7264        }];
7265        assert!(
7266            ungrounded_summary_claims("cargo check passed.", &cargo_check).is_empty(),
7267            "cargo check receipt should ground both build and check claims"
7268        );
7269
7270        let diff_check = [AssistantToolReceipt {
7271            tool: "shell".into(),
7272            call_id: Some("s2".into()),
7273            sequence: None,
7274            ok: true,
7275            params: json!({ "command": "git diff --check" }),
7276            result: None,
7277            via: None,
7278        }];
7279        assert!(
7280            ungrounded_summary_claims("All checks are green.", &diff_check).is_empty(),
7281            "diff-check receipt should ground generic check claims"
7282        );
7283
7284        let tests = [AssistantToolReceipt {
7285            tool: "shell".into(),
7286            call_id: Some("s3".into()),
7287            sequence: None,
7288            ok: true,
7289            params: json!({ "command": "npm run test -- --watch=false" }),
7290            result: None,
7291            via: None,
7292        }];
7293        assert!(
7294            ungrounded_summary_claims("Verified with npm run test.", &tests).is_empty(),
7295            "npm run test receipt should ground verification test claims"
7296        );
7297
7298        assert_eq!(
7299            ungrounded_summary_claims("ctest passed.", &[]),
7300            vec!["tests were run/passed"]
7301        );
7302
7303        let ctest = [AssistantToolReceipt {
7304            tool: "shell".into(),
7305            call_id: Some("s4".into()),
7306            sequence: None,
7307            ok: true,
7308            params: json!({ "command": "ctest --test-dir build --output-on-failure" }),
7309            result: None,
7310            via: None,
7311        }];
7312        assert!(
7313            ungrounded_summary_claims("ctest passed.", &ctest).is_empty(),
7314            "ctest receipt should ground CMake test claims"
7315        );
7316
7317        let cmake_build = [AssistantToolReceipt {
7318            tool: "shell".into(),
7319            call_id: Some("s5".into()),
7320            sequence: None,
7321            ok: true,
7322            params: json!({ "command": "cmake -S . -B build && cmake --build build" }),
7323            result: None,
7324            via: None,
7325        }];
7326        assert!(
7327            ungrounded_summary_claims("CMake build succeeded.", &cmake_build).is_empty(),
7328            "cmake --build receipt should ground CMake build claims"
7329        );
7330
7331        let pnpm_check = [AssistantToolReceipt {
7332            tool: "shell".into(),
7333            call_id: Some("s6".into()),
7334            sequence: None,
7335            ok: true,
7336            params: json!({ "command": "pnpm check" }),
7337            result: None,
7338            via: None,
7339        }];
7340        assert!(
7341            ungrounded_summary_claims("Checks passed.", &pnpm_check).is_empty(),
7342            "package check receipts should ground generic check claims"
7343        );
7344    }
7345
7346    #[test]
7347    fn production_investigation_claims_require_matching_live_receipts() {
7348        let summary = "Repository is clean and HEAD matches origin. Application Insights telemetry showed no recurrence. The production portal page was inspected.";
7349        assert_eq!(
7350            ungrounded_summary_claims(summary, &[]),
7351            vec![
7352                "repository cleanliness was verified",
7353                "repository revision/remote relationship was verified",
7354                "live Application Insights evidence was observed",
7355                "production browser state was observed",
7356            ]
7357        );
7358
7359        let receipts = vec![
7360            AssistantToolReceipt {
7361                tool: "shell".into(),
7362                call_id: Some("git".into()),
7363                sequence: None,
7364                ok: true,
7365                params: json!({"command": "git status && git rev-parse HEAD && git rev-parse origin/main"}),
7366                result: None,
7367                via: None,
7368            },
7369            AssistantToolReceipt {
7370                tool: "shell".into(),
7371                call_id: Some("ai".into()),
7372                sequence: None,
7373                ok: true,
7374                params: json!({"command": "az monitor app-insights query --analytics-query 'exceptions | summarize count()'"}),
7375                result: None,
7376                via: None,
7377            },
7378            AssistantToolReceipt {
7379                tool: "browse_observe".into(),
7380                call_id: Some("browser".into()),
7381                sequence: None,
7382                ok: true,
7383                params: json!({}),
7384                result: None,
7385                via: None,
7386            },
7387        ];
7388        assert!(ungrounded_summary_claims(summary, &receipts).is_empty());
7389
7390        let cautious = "Source tests assert Information-level logging without an exception object.\nApplication Insights query: not obtained.\nProduction browser state: not obtained.\nCannot determine whether N744JS is outside the subscription or whether deployment 23708 fixed the issue.";
7391        assert!(
7392            ungrounded_summary_claims(cautious, &[]).is_empty(),
7393            "explicitly source-scoped and negated claims must not be rejected"
7394        );
7395    }
7396
7397    #[test]
7398    fn summary_file_claim_grounding_requires_matching_named_path() {
7399        let other_edit = [AssistantToolReceipt {
7400            tool: "edit_file".into(),
7401            call_id: Some("e1".into()),
7402            sequence: None,
7403            ok: true,
7404            params: json!({ "path": "src/other.rs" }),
7405            result: None,
7406            via: None,
7407        }];
7408        assert_eq!(
7409            ungrounded_summary_claims("Updated file src/lib.rs.", &other_edit),
7410            vec!["files were created/updated"]
7411        );
7412
7413        let matching_edit = [AssistantToolReceipt {
7414            tool: "edit_file".into(),
7415            call_id: Some("e2".into()),
7416            sequence: None,
7417            ok: true,
7418            params: json!({ "path": "./src/lib.rs" }),
7419            result: None,
7420            via: None,
7421        }];
7422        assert!(
7423            ungrounded_summary_claims("Updated file src/lib.rs.", &matching_edit).is_empty(),
7424            "matching edit_file path should ground the specific update claim"
7425        );
7426
7427        let shell_touch = [AssistantToolReceipt {
7428            tool: "shell".into(),
7429            call_id: Some("s1".into()),
7430            sequence: None,
7431            ok: true,
7432            params: json!({ "command": "touch src/lib.rs" }),
7433            result: None,
7434            via: None,
7435        }];
7436        assert!(
7437            ungrounded_summary_claims("Created file src/lib.rs.", &shell_touch).is_empty(),
7438            "matching shell command path should ground the specific creation claim"
7439        );
7440    }
7441
7442    #[test]
7443    fn summary_read_claim_grounding_requires_matching_named_path() {
7444        let other_read = [AssistantToolReceipt {
7445            tool: "read_file".into(),
7446            call_id: Some("r1".into()),
7447            sequence: None,
7448            ok: true,
7449            params: json!({ "path": "src/other.rs" }),
7450            result: None,
7451            via: None,
7452        }];
7453        assert_eq!(
7454            ungrounded_summary_claims("Inspected file src/lib.rs.", &other_read),
7455            vec!["files were read/inspected"]
7456        );
7457
7458        let matching_read = [AssistantToolReceipt {
7459            tool: "read_file".into(),
7460            call_id: Some("r2".into()),
7461            sequence: None,
7462            ok: true,
7463            params: json!({ "path": "src/lib.rs" }),
7464            result: None,
7465            via: None,
7466        }];
7467        assert!(
7468            ungrounded_summary_claims("Inspected file src/lib.rs.", &matching_read).is_empty(),
7469            "matching read_file path should ground the specific inspection claim"
7470        );
7471
7472        let generic_update = [AssistantToolReceipt {
7473            tool: "edit_file".into(),
7474            call_id: Some("e1".into()),
7475            sequence: None,
7476            ok: true,
7477            params: json!({ "path": "src/lib.rs" }),
7478            result: None,
7479            via: None,
7480        }];
7481        assert!(
7482            ungrounded_summary_claims("Updated files.", &generic_update).is_empty(),
7483            "generic file claims should keep the existing tool-class grounding"
7484        );
7485    }
7486
7487    /// End-to-end goal loop over REAL ground truth: the model uses the real
7488    /// `shell` tool to create a file; the deterministic `Command` condition
7489    /// reads the real filesystem; the loop re-drives until it converges. This
7490    /// is the behavior `/goal` cannot guarantee — completion is decided by the
7491    /// runtime, not a transcript read.
7492    #[tokio::test]
7493    async fn goal_loop_converges_when_the_command_check_passes() {
7494        use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
7495
7496        let dir = tempfile::tempdir().unwrap();
7497        let rt = runtime_for(dir.path()).await;
7498
7499        // Iteration 1: prose only (no tool) — no progress, goal not met.
7500        // Iteration 2: shell-create the file, then finish. File now exists.
7501        let create = crate::coder::test_cmds::touch("donefile");
7502        let script = Script {
7503            turns: vec![
7504                turn("Let me start.", json!([])),
7505                turn(
7506                    "creating it",
7507                    json!([{ "id": "s1", "name": "shell", "arguments": { "command": create } }]),
7508                ),
7509                turn("Done — created donefile.", json!([])),
7510            ],
7511            cursor: AtomicUsize::new(0),
7512        };
7513
7514        let spec = GoalSpec {
7515            goal: "create a file named donefile".into(),
7516            condition: GoalCondition::Command {
7517                id: "donefile".into(),
7518                expect_exit: 0,
7519            },
7520            governor: GoalGovernor {
7521                max_turns: Some(5),
7522                ..Default::default()
7523            },
7524        };
7525
7526        let mut messages = vec![Message::System {
7527            content: "sys".into(),
7528        }];
7529        let never = std::sync::atomic::AtomicBool::new(false);
7530        let donefile = dir.path().join("donefile");
7531        let mut events = Vec::new();
7532
7533        let result = run_assistant_goal_loop(
7534            &script,
7535            &rt,
7536            &cfg(),
7537            &mut messages,
7538            &never,
7539            None,
7540            &spec,
7541            |_outcome| {
7542                // The deterministic check: does the file exist on disk?
7543                let exists = donefile.exists();
7544                async move {
7545                    let mut g = car_engine::GoalGather::default();
7546                    g.command_exits
7547                        .insert("donefile".into(), if exists { 0 } else { 1 });
7548                    g
7549                }
7550            },
7551            |e| events.push(e),
7552        )
7553        .await;
7554
7555        assert_eq!(
7556            result.run.status,
7557            GoalStatus::Achieved,
7558            "{:?}",
7559            result.run.last_reason
7560        );
7561        assert_eq!(
7562            result.run.iterations, 2,
7563            "should converge on the 2nd iteration"
7564        );
7565        assert!(
7566            result.run.grounded,
7567            "a Command-check completion is grounded"
7568        );
7569        assert!(donefile.exists(), "the real file must have been created");
7570        assert_eq!(
7571            result.outcome.models_served.len(),
7572            3,
7573            "the terminal goal-run receipt must retain model calls from every iteration"
7574        );
7575        assert!(result
7576            .outcome
7577            .models_served
7578            .iter()
7579            .all(|attribution| attribution.model_id == "scripted"));
7580        let checks: Vec<_> = events
7581            .iter()
7582            .filter_map(|e| match e {
7583                AssistantEvent::GoalEvaluated {
7584                    iteration,
7585                    met,
7586                    grounded,
7587                    reason,
7588                } => Some((*iteration, *met, *grounded, reason.as_str())),
7589                _ => None,
7590            })
7591            .collect();
7592        assert_eq!(checks.len(), 2, "one verifier event per goal iteration");
7593        assert_eq!(checks[0].0, 1);
7594        assert!(
7595            !checks[0].1,
7596            "first iteration should not meet the command condition"
7597        );
7598        assert_eq!(checks[1].0, 2);
7599        assert!(
7600            checks[1].1,
7601            "second iteration should meet the command condition"
7602        );
7603        assert!(checks[1].2, "command-backed completion is grounded");
7604
7605        let log = rt.log.lock().await;
7606        let goal_events: Vec<_> = log
7607            .events()
7608            .iter()
7609            .filter(|e| e.kind == car_eventlog::EventKind::GoalEvaluated)
7610            .collect();
7611        assert_eq!(
7612            goal_events.len(),
7613            2,
7614            "event log should audit each verifier pass"
7615        );
7616        assert_eq!(goal_events[0].data.get("iteration"), Some(&json!(1)));
7617        assert_eq!(goal_events[0].data.get("met"), Some(&json!(false)));
7618        assert_eq!(
7619            goal_events[1].data.get("goal"),
7620            Some(&json!("create a file named donefile"))
7621        );
7622        assert_eq!(
7623            goal_events[1].data.get("condition"),
7624            Some(&json!({"kind": "command", "id": "donefile", "expect_exit": 0}))
7625        );
7626        assert_eq!(goal_events[1].data.get("iteration"), Some(&json!(2)));
7627        assert_eq!(goal_events[1].data.get("met"), Some(&json!(true)));
7628        assert_eq!(goal_events[1].data.get("grounded"), Some(&json!(true)));
7629    }
7630
7631    /// F9 regression: a deterministic goal check that PASSED must not be
7632    /// re-opened just because the final prose named an operational claim with no
7633    /// matching tool receipt. The loop achieves on the first pass, records the
7634    /// completion as grounded (ground truth verified it), and the prose mismatch
7635    /// travels only as a non-authoritative note on the reply text.
7636    #[tokio::test]
7637    async fn deterministic_pass_not_reopened_by_ungrounded_prose() {
7638        use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
7639
7640        let dir = tempfile::tempdir().unwrap();
7641        let rt = runtime_for(dir.path()).await;
7642        // The deterministic command check exits 0, but the model claims "tests
7643        // passed" with no matching shell receipt this run.
7644        let script = Script {
7645            turns: vec![turn("I ran the tests and they passed.", json!([]))],
7646            cursor: AtomicUsize::new(0),
7647        };
7648        let spec = GoalSpec {
7649            goal: "make tests pass".into(),
7650            condition: GoalCondition::Command {
7651                id: "tests".into(),
7652                expect_exit: 0,
7653            },
7654            governor: GoalGovernor {
7655                max_turns: Some(3),
7656                ..Default::default()
7657            },
7658        };
7659        let mut messages = vec![Message::System {
7660            content: "sys".into(),
7661        }];
7662        let never = std::sync::atomic::AtomicBool::new(false);
7663        let mut events = Vec::new();
7664
7665        let result = run_assistant_goal_loop(
7666            &script,
7667            &rt,
7668            &cfg(),
7669            &mut messages,
7670            &never,
7671            None,
7672            &spec,
7673            |_outcome| async move {
7674                let mut g = car_engine::GoalGather::default();
7675                g.command_exits.insert("tests".into(), 0);
7676                g
7677            },
7678            |e| events.push(e),
7679        )
7680        .await;
7681
7682        // Achieved on the FIRST pass — the deterministic command check decided
7683        // completion; the ungrounded prose did not re-drive the loop.
7684        assert_eq!(
7685            result.run.status,
7686            GoalStatus::Achieved,
7687            "{:?}",
7688            result.run.last_reason
7689        );
7690        assert_eq!(result.run.iterations, 1);
7691        assert!(result.run.grounded, "command-backed completion is grounded");
7692        assert_eq!(result.run.evidence.len(), 1);
7693        assert!(result.run.evidence[0].met && result.run.evidence[0].grounded);
7694        // The unverified claim is annotated onto the returned reply text.
7695        assert!(
7696            result.outcome.summary.contains("[claim check]")
7697                && result.outcome.summary.contains("tests were run/passed"),
7698            "summary should carry the claim-check note: {}",
7699            result.outcome.summary
7700        );
7701        // ...but NEVER into the persisted `messages` thread (would leak into
7702        // later turns' context via chat.rs's thread persistence).
7703        assert!(
7704            !serde_json::to_string(&messages)
7705                .unwrap_or_default()
7706                .contains("[claim check]"),
7707            "the claim-check note must not leak into the thread messages"
7708        );
7709        // The streamed GoalEvaluated verdict stays grounded=true.
7710        let streamed: Vec<_> = events
7711            .iter()
7712            .filter_map(|e| match e {
7713                AssistantEvent::GoalEvaluated { grounded, .. } => Some(*grounded),
7714                _ => None,
7715            })
7716            .collect();
7717        assert_eq!(streamed, vec![true], "streamed verdict stays grounded=true");
7718        // The durable GoalEvaluated records grounded=true and a CLEAN reason —
7719        // the prose mismatch is not folded as a false-completion failure signal.
7720        let log = rt.log.lock().await;
7721        let goal_events: Vec<_> = log
7722            .events()
7723            .iter()
7724            .filter(|e| e.kind == car_eventlog::EventKind::GoalEvaluated)
7725            .collect();
7726        assert_eq!(goal_events.len(), 1);
7727        assert_eq!(goal_events[0].data.get("met"), Some(&json!(true)));
7728        assert_eq!(goal_events[0].data.get("grounded"), Some(&json!(true)));
7729        assert!(
7730            !goal_events[0]
7731                .data
7732                .get("reason")
7733                .and_then(|r| r.as_str())
7734                .unwrap_or("")
7735                .contains("ungrounded assistant summary claim"),
7736            "durable reason must not record the prose mismatch as a failure"
7737        );
7738    }
7739
7740    /// The fail-closed path is UNCHANGED when the met verdict is NOT a
7741    /// deterministic pass: a `ModelJudge`-satisfied goal is `grounded=false`, so
7742    /// an ungrounded summary claim keeps `grounded=false`, `met && grounded`
7743    /// never holds, and the loop halts on the governor. The Phase-0 miners keep
7744    /// receiving the ungrounded `GoalEvaluated` signal.
7745    #[tokio::test]
7746    async fn ungrounded_claim_without_deterministic_pass_still_fails_closed() {
7747        use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
7748
7749        let dir = tempfile::tempdir().unwrap();
7750        let rt = runtime_for(dir.path()).await;
7751        let script = Script {
7752            turns: vec![turn("I ran the tests and they passed.", json!([]))],
7753            cursor: AtomicUsize::new(0),
7754        };
7755        // Model-judge completion: met is decided by a transcript-only verdict, so
7756        // the verdict is grounded=false — NOT a deterministic pass.
7757        let spec = GoalSpec {
7758            goal: "make tests pass".into(),
7759            condition: GoalCondition::ModelJudge { id: "judge".into() },
7760            governor: GoalGovernor {
7761                max_turns: Some(1),
7762                ..Default::default()
7763            },
7764        };
7765        let mut messages = vec![Message::System {
7766            content: "sys".into(),
7767        }];
7768        let never = std::sync::atomic::AtomicBool::new(false);
7769
7770        let result = run_assistant_goal_loop(
7771            &script,
7772            &rt,
7773            &cfg(),
7774            &mut messages,
7775            &never,
7776            None,
7777            &spec,
7778            |_outcome| async move {
7779                let mut g = car_engine::GoalGather::default();
7780                g.model_verdicts.insert("judge".into(), true);
7781                g
7782            },
7783            |_| {},
7784        )
7785        .await;
7786
7787        assert_eq!(
7788            result.run.status,
7789            GoalStatus::Halted {
7790                halt: GoalHalt::TurnBudget
7791            }
7792        );
7793        assert_eq!(result.run.evidence.len(), 1);
7794        assert!(result.run.evidence[0].met);
7795        assert!(
7796            !result.run.evidence[0].grounded,
7797            "a model-judge completion with an ungrounded claim stays ungrounded"
7798        );
7799        assert!(result
7800            .run
7801            .last_reason
7802            .contains("ungrounded assistant summary claim"));
7803        // The claim travels in the verdict reason as before — NOT as a reply note
7804        // (annotation is exclusive to the deterministic-pass path).
7805        assert!(!result.outcome.summary.contains("[claim check]"));
7806    }
7807
7808    /// On a deterministic pass, prose whose operational claim IS backed by a
7809    /// same-run receipt is left unannotated — the claim-check note only appears
7810    /// for genuinely unmatched claims.
7811    #[tokio::test]
7812    async fn grounded_prose_on_deterministic_pass_unannotated() {
7813        use car_verify::goal::{GoalCondition, GoalGovernor, GoalSpec, GoalStatus};
7814
7815        let dir = tempfile::tempdir().unwrap();
7816        let rt = runtime_for(dir.path()).await;
7817        // The model actually creates the file via shell, then claims it — the
7818        // "files were created/updated" claim is grounded by the create receipt
7819        // (which is why WRITE_TERMS must know the cmd spelling too, not just
7820        // POSIX `touch`).
7821        let create = crate::coder::test_cmds::touch("donefile");
7822        let script = Script {
7823            turns: vec![
7824                turn(
7825                    "creating it",
7826                    json!([{ "id": "s1", "name": "shell", "arguments": { "command": create } }]),
7827                ),
7828                turn("Done — created donefile.", json!([])),
7829            ],
7830            cursor: AtomicUsize::new(0),
7831        };
7832        let spec = GoalSpec {
7833            goal: "create a file named donefile".into(),
7834            condition: GoalCondition::Command {
7835                id: "donefile".into(),
7836                expect_exit: 0,
7837            },
7838            governor: GoalGovernor {
7839                max_turns: Some(3),
7840                ..Default::default()
7841            },
7842        };
7843        let mut messages = vec![Message::System {
7844            content: "sys".into(),
7845        }];
7846        let never = std::sync::atomic::AtomicBool::new(false);
7847        let donefile = dir.path().join("donefile");
7848
7849        let result = run_assistant_goal_loop(
7850            &script,
7851            &rt,
7852            &cfg(),
7853            &mut messages,
7854            &never,
7855            None,
7856            &spec,
7857            |_outcome| {
7858                let exists = donefile.exists();
7859                async move {
7860                    let mut g = car_engine::GoalGather::default();
7861                    g.command_exits
7862                        .insert("donefile".into(), if exists { 0 } else { 1 });
7863                    g
7864                }
7865            },
7866            |_| {},
7867        )
7868        .await;
7869
7870        assert_eq!(
7871            result.run.status,
7872            GoalStatus::Achieved,
7873            "{:?}",
7874            result.run.last_reason
7875        );
7876        assert_eq!(result.run.iterations, 1);
7877        assert!(result.run.grounded);
7878        // No claim-check note: the file-write claim matched the shell receipt.
7879        assert_eq!(result.outcome.summary, "Done — created donefile.");
7880        assert!(!result.outcome.summary.contains("[claim check]"));
7881    }
7882
7883    /// A goal that can never be met halts on the governor's turn budget — a
7884    /// hard bound, not `/goal`'s soft "or stop after N turns" prose.
7885    #[tokio::test]
7886    async fn goal_loop_halts_on_turn_budget() {
7887        use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
7888
7889        let dir = tempfile::tempdir().unwrap();
7890        let rt = runtime_for(dir.path()).await;
7891
7892        // The model always just finishes with prose; the file is never created.
7893        struct Idle;
7894        #[async_trait]
7895        impl TurnGenerator for Idle {
7896            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
7897                Ok(turn("thinking...", json!([])))
7898            }
7899        }
7900
7901        let spec = GoalSpec {
7902            goal: "impossible".into(),
7903            condition: GoalCondition::Command {
7904                id: "never".into(),
7905                expect_exit: 0,
7906            },
7907            governor: GoalGovernor {
7908                max_turns: Some(3),
7909                ..Default::default()
7910            },
7911        };
7912        let mut messages = vec![Message::System {
7913            content: "sys".into(),
7914        }];
7915        let never = std::sync::atomic::AtomicBool::new(false);
7916
7917        let result = run_assistant_goal_loop(
7918            &Idle,
7919            &rt,
7920            &cfg(),
7921            &mut messages,
7922            &never,
7923            None,
7924            &spec,
7925            |_o| async {
7926                let mut g = car_engine::GoalGather::default();
7927                g.command_exits.insert("never".into(), 1);
7928                g
7929            },
7930            |_e| {},
7931        )
7932        .await;
7933
7934        assert_eq!(
7935            result.run.status,
7936            GoalStatus::Halted {
7937                halt: GoalHalt::TurnBudget
7938            }
7939        );
7940        assert_eq!(result.run.iterations, 3);
7941    }
7942
7943    /// car#1112: a `gather` that never resolves — the stand-in for a stuck
7944    /// approval wait, a wedged subprocess, or (were `ModelJudge` ever wired
7945    /// into a caller) an inference call to a dead route — must not hang the
7946    /// turn forever, and must not discard the primary reply the model already
7947    /// produced. Before this fix there was no bound on `gather().await` at
7948    /// all; `#[tokio::test(start_paused = true)]` proves the loop's own
7949    /// timeout is what ends this run, not a wall-clock coincidence — a real
7950    /// build would hang here without it.
7951    #[tokio::test(start_paused = true)]
7952    async fn goal_loop_fails_open_when_the_check_never_resolves() {
7953        use car_verify::goal::{GoalCondition, GoalGovernor, GoalHalt, GoalSpec, GoalStatus};
7954
7955        let dir = tempfile::tempdir().unwrap();
7956        let rt = runtime_for(dir.path()).await;
7957
7958        // The model answers cleanly on the very first iteration. This reply
7959        // is what must reach the caller regardless of what the (stuck) check
7960        // does next.
7961        struct Answers;
7962        #[async_trait]
7963        impl TurnGenerator for Answers {
7964            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
7965                Ok(turn("Here is your answer.", json!([])))
7966            }
7967        }
7968
7969        let spec = GoalSpec {
7970            goal: "answer the question".into(),
7971            condition: GoalCondition::Command {
7972                id: "verify".into(),
7973                expect_exit: 0,
7974            },
7975            governor: GoalGovernor {
7976                // Deliberately generous — a fix that only works because the
7977                // turn budget also happens to be tiny isn't the fix under
7978                // test. If the evaluation timeout weren't wired in, this run
7979                // would hang forever well before ever spending a 2nd turn.
7980                max_turns: Some(8),
7981                ..Default::default()
7982            },
7983        };
7984        let mut messages = vec![Message::System {
7985            content: "sys".into(),
7986        }];
7987        let never = std::sync::atomic::AtomicBool::new(false);
7988        let mut events = Vec::new();
7989
7990        let result = run_assistant_goal_loop(
7991            &Answers,
7992            &rt,
7993            &cfg(),
7994            &mut messages,
7995            &never,
7996            None,
7997            &spec,
7998            |_outcome| std::future::pending::<car_engine::GoalGather>(),
7999            |e| events.push(e),
8000        )
8001        .await;
8002
8003        assert_eq!(
8004            result.run.status,
8005            GoalStatus::Halted {
8006                halt: GoalHalt::EvaluationTimeout
8007            },
8008            "{:?}",
8009            result.run.last_reason
8010        );
8011        assert_eq!(
8012            result.run.iterations, 1,
8013            "must halt on the FIRST stuck evaluation, not burn the rest of the turn budget \
8014             re-running the model against a check that can never be graded"
8015        );
8016        assert!(
8017            result.run.last_reason.contains("did not complete within"),
8018            "{}",
8019            result.run.last_reason
8020        );
8021        assert_eq!(
8022            result.outcome.summary, "Here is your answer.",
8023            "the primary reply must survive an evaluation pass that never resolves"
8024        );
8025        assert!(
8026            events.iter().any(|e| matches!(
8027                e,
8028                AssistantEvent::GoalEvaluated {
8029                    met: false,
8030                    grounded: false,
8031                    ..
8032                }
8033            )),
8034            "the unevaluated outcome must still be streamed as a goal_evaluated event — \
8035             grounded: false, not true: there is no verdict to be grounded, the check \
8036             never ran (car#1113 review)"
8037        );
8038        assert!(
8039            !result.run.grounded,
8040            "GoalRun.grounded must not claim a deterministic verdict exists when the \
8041             check never got the chance to run"
8042        );
8043    }
8044
8045    struct FixedGate(bool);
8046    #[async_trait]
8047    impl ApprovalGate for FixedGate {
8048        async fn request(&self, _tool: &str, _params: &Value) -> ApprovalDecision {
8049            if self.0 {
8050                ApprovalDecision::Approved
8051            } else {
8052                ApprovalDecision::Denied("user declined".into())
8053            }
8054        }
8055    }
8056
8057    struct CapturingGen {
8058        images_seen: std::sync::Arc<std::sync::Mutex<Option<usize>>>,
8059    }
8060    #[async_trait]
8061    impl TurnGenerator for CapturingGen {
8062        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
8063            *self.images_seen.lock().unwrap() = req.images.as_ref().map(|v| v.len());
8064            Ok(turn("done", json!([]))) // no tool calls → finish on turn 1
8065        }
8066    }
8067
8068    #[tokio::test]
8069    async fn images_are_attached_to_the_first_request() {
8070        let dir = tempfile::tempdir().unwrap();
8071        let rt = runtime_for(dir.path()).await;
8072        let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
8073        let generator = CapturingGen {
8074            images_seen: seen.clone(),
8075        };
8076        let img = ContentBlock::ImageUrl {
8077            url: "https://example.com/x.png".into(),
8078            detail: "auto".into(),
8079        };
8080        let mut messages = vec![
8081            Message::System {
8082                content: "s".into(),
8083            },
8084            Message::User {
8085                content: "describe".into(),
8086            },
8087        ];
8088        let never = std::sync::atomic::AtomicBool::new(false);
8089        let imgs = [img];
8090        run_assistant_loop_cancellable(
8091            &generator,
8092            &rt,
8093            &cfg(),
8094            &mut messages,
8095            &never,
8096            None,
8097            Some(&imgs),
8098            |_| {},
8099        )
8100        .await;
8101        assert_eq!(
8102            *seen.lock().unwrap(),
8103            Some(1),
8104            "the image should reach the first request"
8105        );
8106    }
8107
8108    #[tokio::test]
8109    async fn gated_tool_is_denied_without_a_gate() {
8110        let dir = tempfile::tempdir().unwrap();
8111        let rt = runtime_for(dir.path()).await;
8112        let script = Script {
8113            turns: vec![
8114                turn(
8115                    "",
8116                    json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "x.txt", "content": "no" } }]),
8117                ),
8118                turn("could not write", json!([])),
8119            ],
8120            cursor: AtomicUsize::new(0),
8121        };
8122        let mut cfg = cfg();
8123        cfg.gated_tools = vec!["write_file".into()];
8124        let mut messages = vec![
8125            Message::System {
8126                content: "s".into(),
8127            },
8128            Message::User {
8129                content: "write x".into(),
8130            },
8131        ];
8132        let never = std::sync::atomic::AtomicBool::new(false);
8133        let outcome = run_assistant_loop_cancellable(
8134            &script,
8135            &rt,
8136            &cfg,
8137            &mut messages,
8138            &never,
8139            None,
8140            None,
8141            |_| {},
8142        )
8143        .await;
8144        assert_eq!(outcome.status, "success");
8145        assert!(
8146            !dir.path().join("x.txt").exists(),
8147            "gated write must not run"
8148        );
8149        assert!(!outcome.tools_called.contains(&"write_file".to_string()));
8150    }
8151
8152    #[tokio::test]
8153    async fn gated_tool_runs_when_approved() {
8154        let dir = tempfile::tempdir().unwrap();
8155        let rt = runtime_for(dir.path()).await;
8156        let script = Script {
8157            turns: vec![
8158                turn(
8159                    "",
8160                    json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "ok.txt", "content": "yes" } }]),
8161                ),
8162                turn("wrote it", json!([])),
8163            ],
8164            cursor: AtomicUsize::new(0),
8165        };
8166        let mut cfg = cfg();
8167        cfg.gated_tools = vec!["write_file".into()];
8168        let gate = FixedGate(true);
8169        let mut messages = vec![
8170            Message::System {
8171                content: "s".into(),
8172            },
8173            Message::User {
8174                content: "write ok".into(),
8175            },
8176        ];
8177        let never = std::sync::atomic::AtomicBool::new(false);
8178        let outcome = run_assistant_loop_cancellable(
8179            &script,
8180            &rt,
8181            &cfg,
8182            &mut messages,
8183            &never,
8184            Some(&gate),
8185            None,
8186            |_| {},
8187        )
8188        .await;
8189        assert_eq!(outcome.status, "success");
8190        assert_eq!(
8191            std::fs::read_to_string(dir.path().join("ok.txt")).unwrap(),
8192            "yes"
8193        );
8194    }
8195
8196    #[tokio::test]
8197    async fn loop_writes_a_file_through_the_runtime() {
8198        let dir = tempfile::tempdir().unwrap();
8199        let rt = runtime_for(dir.path()).await;
8200        let script = Script {
8201            turns: vec![
8202                turn(
8203                    "",
8204                    json!([{ "id": "w1", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
8205                ),
8206                turn("Wrote hi.txt.", json!([])),
8207            ],
8208            cursor: AtomicUsize::new(0),
8209        };
8210        let mut messages = vec![
8211            Message::System {
8212                content: "sys".into(),
8213            },
8214            Message::User {
8215                content: "write hi.txt".into(),
8216            },
8217        ];
8218        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8219        assert_eq!(outcome.status, "success");
8220        assert_eq!(
8221            std::fs::read_to_string(dir.path().join("hi.txt")).unwrap(),
8222            "hello"
8223        );
8224    }
8225
8226    // ---- response_format passthrough + one-shot repair ----
8227
8228    fn json_object_cfg() -> AssistantConfig {
8229        AssistantConfig {
8230            response_format: Some(car_inference::ResponseFormat::JsonObject),
8231            ..cfg()
8232        }
8233    }
8234
8235    fn repair_notices(events: &[AssistantEvent]) -> (usize, usize) {
8236        let mut fired = 0;
8237        let mut still_invalid = 0;
8238        for e in events {
8239            if let AssistantEvent::Text(t) = e {
8240                if t == FORMAT_REPAIR_NOTICE {
8241                    fired += 1;
8242                }
8243                if t == FORMAT_REPAIR_STILL_INVALID {
8244                    still_invalid += 1;
8245                }
8246            }
8247        }
8248        (fired, still_invalid)
8249    }
8250
8251    /// (a) The format is NEVER on a turn that offers tools — it suppresses
8252    /// tool use on real providers — and appears only on the tool-less repair
8253    /// turn. A run that offers no tools carries it on every turn.
8254    #[tokio::test]
8255    async fn response_format_is_never_on_tool_turns_only_on_the_repair_turn() {
8256        let dir = tempfile::tempdir().unwrap();
8257        let rt = runtime_for(dir.path()).await;
8258        let tool_turn = || {
8259            turn(
8260                "computing",
8261                json!([{ "id": "c", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8262            )
8263        };
8264
8265        // With tools: tool turn, non-JSON final, repair.
8266        let seen = Arc::new(StdMutex::new(Vec::new()));
8267        let script = CapturingScript {
8268            turns: vec![
8269                tool_turn(),
8270                turn("The sum is 2.", json!([])),
8271                turn(r#"{"sum": 2}"#, json!([])),
8272            ],
8273            cursor: AtomicUsize::new(0),
8274            seen: Arc::clone(&seen),
8275        };
8276        let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8277        let outcome =
8278            run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |_| {}).await;
8279        assert_eq!(outcome.status, "success");
8280        assert_eq!(outcome.summary, r#"{"sum": 2}"#);
8281        {
8282            let reqs = seen.lock().unwrap();
8283            assert_eq!(reqs.len(), 3);
8284            for (i, r) in reqs[..2].iter().enumerate() {
8285                assert!(r.tools.is_some(), "request {i} offers tools");
8286                assert!(
8287                    r.response_format.is_none(),
8288                    "request {i} offers tools, so it must not be JSON-constrained"
8289                );
8290            }
8291            assert!(reqs[2].tools.is_none(), "the repair turn offers no tools");
8292            assert_eq!(
8293                reqs[2].response_format,
8294                Some(car_inference::ResponseFormat::JsonObject),
8295                "and is the one request that carries the format"
8296            );
8297        }
8298
8299        // Without tools there is nothing to suppress: every turn carries it.
8300        let seen = Arc::new(StdMutex::new(Vec::new()));
8301        let script = CapturingScript {
8302            turns: vec![turn(r#"{"sum": 2}"#, json!([]))],
8303            cursor: AtomicUsize::new(0),
8304            seen: Arc::clone(&seen),
8305        };
8306        let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8307        let no_tools_cfg = AssistantConfig {
8308            tools: Vec::new(),
8309            ..json_object_cfg()
8310        };
8311        let outcome = run_assistant_loop(&script, &rt, &no_tools_cfg, &mut messages, |_| {}).await;
8312        assert_eq!(outcome.status, "success");
8313        {
8314            let reqs = seen.lock().unwrap();
8315            assert_eq!(reqs.len(), 1, "a valid answer costs no extra call");
8316            assert!(reqs[0].tools.is_none());
8317            assert_eq!(
8318                reqs[0].response_format,
8319                Some(car_inference::ResponseFormat::JsonObject)
8320            );
8321        }
8322
8323        // No format configured: none anywhere.
8324        let seen = Arc::new(StdMutex::new(Vec::new()));
8325        let script = CapturingScript {
8326            turns: vec![tool_turn(), turn("two", json!([]))],
8327            cursor: AtomicUsize::new(0),
8328            seen: Arc::clone(&seen),
8329        };
8330        let mut messages = vec![sys("sys"), usr("add 1 and 1")];
8331        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8332        assert_eq!(outcome.status, "success");
8333        let reqs = seen.lock().unwrap();
8334        assert_eq!(reqs.len(), 2);
8335        assert!(reqs.iter().all(|r| r.response_format.is_none()));
8336    }
8337
8338    /// (b) A final answer that is not the requested shape triggers EXACTLY
8339    /// one repair call: no tools, the format set, the draft + nudge in the
8340    /// transcript. The repaired text is the answer and the event stream says
8341    /// the repair happened.
8342    #[tokio::test]
8343    async fn invalid_final_answer_triggers_exactly_one_toolless_repair() {
8344        let dir = tempfile::tempdir().unwrap();
8345        let rt = runtime_for(dir.path()).await;
8346        let seen = Arc::new(StdMutex::new(Vec::new()));
8347        let script = CapturingScript {
8348            turns: vec![
8349                turn("Sure! The answer is: sum = 2.", json!([])),
8350                turn(r#"{"sum": 2}"#, json!([])),
8351            ],
8352            cursor: AtomicUsize::new(0),
8353            seen: Arc::clone(&seen),
8354        };
8355        let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8356        let mut events = Vec::new();
8357        let repair_cfg = AssistantConfig {
8358            model: Some("newsroom-editor".into()),
8359            strict_model: true,
8360            ..json_object_cfg()
8361        };
8362        let outcome =
8363            run_assistant_loop(&script, &rt, &repair_cfg, &mut messages, |e| events.push(e)).await;
8364        assert_eq!(outcome.status, "success");
8365        assert_eq!(
8366            outcome.summary, r#"{"sum": 2}"#,
8367            "the repaired text is the answer"
8368        );
8369        assert_eq!(
8370            outcome.turns, 1,
8371            "a repair is a model call, not a loop turn"
8372        );
8373
8374        let reqs = seen.lock().unwrap();
8375        assert_eq!(reqs.len(), 2, "draft + exactly one repair");
8376        for (index, request) in reqs.iter().enumerate() {
8377            assert_eq!(
8378                request.model.as_deref(),
8379                Some("newsroom-editor"),
8380                "request {index} must retain the configured editor model"
8381            );
8382            assert!(
8383                request.params.strict_model,
8384                "request {index} must retain strict model selection"
8385            );
8386            assert_eq!(
8387                request.expected_row_digest, None,
8388                "assistant config exposes no immutable row precondition"
8389            );
8390            assert_eq!(
8391                request.expected_catalog_revision, None,
8392                "assistant config exposes no catalog revision precondition"
8393            );
8394        }
8395        let repair = &reqs[1];
8396        assert!(
8397            repair.tools.is_none(),
8398            "the repair turn advertises no tools"
8399        );
8400        assert_eq!(
8401            repair.response_format,
8402            Some(car_inference::ResponseFormat::JsonObject)
8403        );
8404        let history = repair.messages.as_ref().unwrap();
8405        assert!(
8406            matches!(history.last(), Some(Message::User { content }) if content == FORMAT_REPAIR_NUDGE),
8407            "the nudge is the last message the repair sees"
8408        );
8409        assert!(
8410            matches!(&history[history.len() - 2], Message::Assistant { content, .. } if content.contains("sum = 2")),
8411            "the draft is in the transcript so the model can see what it got wrong"
8412        );
8413
8414        let (fired, still_invalid) = repair_notices(&events);
8415        assert_eq!(fired, 1, "the repair must be visible in the event stream");
8416        assert_eq!(still_invalid, 0);
8417        assert!(
8418            matches!(events.last(), Some(AssistantEvent::Done { text }) if text == r#"{"sum": 2}"#)
8419        );
8420        // The durable transcript records the whole exchange: draft, nudge, repair.
8421        assert!(
8422            matches!(messages.last(), Some(Message::Assistant { content, .. }) if content == r#"{"sum": 2}"#)
8423        );
8424        assert!(
8425            matches!(&messages[messages.len() - 2], Message::User { content } if content == FORMAT_REPAIR_NUDGE)
8426        );
8427    }
8428
8429    /// (c) A valid final answer triggers no repair — one request, no notice.
8430    #[tokio::test]
8431    async fn valid_final_answer_triggers_no_repair() {
8432        let dir = tempfile::tempdir().unwrap();
8433        let rt = runtime_for(dir.path()).await;
8434        let seen = Arc::new(StdMutex::new(Vec::new()));
8435        let script = CapturingScript {
8436            // A fenced object is accepted: models emit the fence even under
8437            // JSON mode, and the payload inside is what the caller parses.
8438            turns: vec![turn("```json\n{\"sum\": 2}\n```", json!([]))],
8439            cursor: AtomicUsize::new(0),
8440            seen: Arc::clone(&seen),
8441        };
8442        let mut messages = vec![sys("sys"), usr("add 1 and 1, answer as JSON")];
8443        let mut events = Vec::new();
8444        let outcome = run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |e| {
8445            events.push(e)
8446        })
8447        .await;
8448        assert_eq!(outcome.status, "success");
8449        assert_eq!(seen.lock().unwrap().len(), 1);
8450        assert_eq!(repair_notices(&events), (0, 0));
8451    }
8452
8453    /// The contract is ONE repair, not retry-until-valid: a second miss is
8454    /// reported and the repaired text returned as-is.
8455    #[tokio::test]
8456    async fn a_repair_that_still_misses_is_reported_not_retried() {
8457        let dir = tempfile::tempdir().unwrap();
8458        let rt = runtime_for(dir.path()).await;
8459        let seen = Arc::new(StdMutex::new(Vec::new()));
8460        let script = CapturingScript {
8461            turns: vec![
8462                turn("not json", json!([])),
8463                turn("still not json", json!([])),
8464                turn(r#"{"never": "reached"}"#, json!([])),
8465            ],
8466            cursor: AtomicUsize::new(0),
8467            seen: Arc::clone(&seen),
8468        };
8469        let mut messages = vec![sys("sys"), usr("answer as JSON")];
8470        let mut events = Vec::new();
8471        let outcome = run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |e| {
8472            events.push(e)
8473        })
8474        .await;
8475        assert_eq!(outcome.status, "success");
8476        assert_eq!(outcome.summary, "still not json");
8477        assert_eq!(seen.lock().unwrap().len(), 2, "one repair, never a second");
8478        assert_eq!(repair_notices(&events), (1, 1));
8479    }
8480
8481    /// `JsonObject` needs an object; `JsonSchema` needs parseable JSON
8482    /// (parse-only: this crate carries no schema validator). Fences are
8483    /// tolerated either way.
8484    #[test]
8485    fn final_text_format_check_semantics() {
8486        use car_inference::ResponseFormat::{JsonObject, JsonSchema};
8487        let schema = JsonSchema {
8488            schema: json!({"type": "array"}),
8489            strict: false,
8490            name: None,
8491        };
8492        assert!(final_text_matches_format(r#"{"a": 1}"#, &JsonObject, None));
8493        assert!(final_text_matches_format(
8494            "```json\n{\"a\": 1}\n```",
8495            &JsonObject,
8496            None
8497        ));
8498        assert!(
8499            !final_text_matches_format("[1, 2]", &JsonObject, None),
8500            "an array is not an object"
8501        );
8502        assert!(!final_text_matches_format(
8503            "Here: {\"a\": 1}",
8504            &JsonObject,
8505            None
8506        ));
8507        assert!(
8508            final_text_matches_format("[1, 2]", &schema, None),
8509            "schema mode is parse-only"
8510        );
8511        assert!(!final_text_matches_format("nope", &schema, None));
8512        let requires_legs: ResponseFormatValidator = Arc::new(|v| v.get("legs").is_some());
8513        assert!(
8514            final_text_matches_format(r#"{"legs": []}"#, &schema, Some(&requires_legs)),
8515            "conforming JSON passes the caller's schema check"
8516        );
8517        assert!(
8518            !final_text_matches_format(r#"{"nope": 1}"#, &schema, Some(&requires_legs)),
8519            "valid JSON of the wrong shape must fail once a validator exists"
8520        );
8521        assert_eq!(extract_json_payload("```\n[1]\n```"), "[1]");
8522        assert_eq!(extract_json_payload("  [1] "), "[1]");
8523        assert_eq!(
8524            extract_json_payload("```json\n{}"),
8525            "```json\n{}",
8526            "an unclosed fence is left alone"
8527        );
8528    }
8529
8530    // ---- context_window_override ----
8531
8532    /// A generator that knows its model's window, so the override has
8533    /// something to be clamped against.
8534    struct WindowedScript {
8535        inner: CapturingScript,
8536        window: usize,
8537    }
8538
8539    #[async_trait]
8540    impl TurnGenerator for WindowedScript {
8541        async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
8542            self.inner.generate(req).await
8543        }
8544        fn context_window(&self, _model: &str) -> usize {
8545            self.window
8546        }
8547    }
8548
8549    fn windowed(window: usize) -> (WindowedScript, Arc<StdMutex<Vec<GenerateRequest>>>) {
8550        let seen = Arc::new(StdMutex::new(Vec::new()));
8551        let script = WindowedScript {
8552            inner: CapturingScript {
8553                turns: vec![turn("done", json!([]))],
8554                cursor: AtomicUsize::new(0),
8555                seen: Arc::clone(&seen),
8556            },
8557            window,
8558        };
8559        (script, seen)
8560    }
8561
8562    /// ~60k estimated tokens: fits a 200k window (budget 150k), overflows a
8563    /// 20k one (budget 15k).
8564    fn long_history() -> Vec<Message> {
8565        let big = "x".repeat(20_000);
8566        let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
8567        for i in 0..12 {
8568            m.push(asst_call(&format!("c{i}")));
8569            m.push(tool_res(&format!("c{i}"), &big));
8570        }
8571        m
8572    }
8573
8574    fn has_compaction_notice(messages: &[Message]) -> bool {
8575        messages.iter().any(|m| {
8576            matches!(m, Message::System { content } if content.starts_with(COMPACTION_NOTICE_PREFIX))
8577        })
8578    }
8579
8580    fn window_advisories(events: &[AssistantEvent]) -> usize {
8581        events
8582            .iter()
8583            .filter(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with("[context window:")))
8584            .count()
8585    }
8586
8587    /// Override 20k on a 200k model: compaction fires on a history that the
8588    /// real window would have carried whole.
8589    #[tokio::test]
8590    async fn context_window_override_below_the_registry_window_tightens_compaction() {
8591        let dir = tempfile::tempdir().unwrap();
8592        let rt = runtime_for(dir.path()).await;
8593        let (script, seen) = windowed(200_000);
8594        let mut messages = long_history();
8595        let cfg = AssistantConfig {
8596            context_window_override: Some(20_000),
8597            refuse_unadvertised_tools: false,
8598            response_format_validator: None,
8599            delegate_budget: None,
8600            ..cfg()
8601        };
8602        let mut events = Vec::new();
8603        let outcome =
8604            run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
8605        assert_eq!(outcome.status, "success");
8606        assert!(
8607            has_compaction_notice(&messages),
8608            "the 20k override must compact"
8609        );
8610        // The compacted history is what the model saw, not just what was stored.
8611        let reqs = seen.lock().unwrap();
8612        assert!(has_compaction_notice(reqs[0].messages.as_ref().unwrap()));
8613        assert_eq!(window_advisories(&events), 0, "tightening is not clamped");
8614    }
8615
8616    /// No override: the registry window governs and this history fits.
8617    #[tokio::test]
8618    async fn no_context_window_override_leaves_the_registry_window_in_charge() {
8619        let dir = tempfile::tempdir().unwrap();
8620        let rt = runtime_for(dir.path()).await;
8621        let (script, _seen) = windowed(200_000);
8622        let mut messages = long_history();
8623        let mut events = Vec::new();
8624        let outcome =
8625            run_assistant_loop(&script, &rt, &cfg(), &mut messages, |e| events.push(e)).await;
8626        assert_eq!(outcome.status, "success");
8627        assert!(!has_compaction_notice(&messages), "60k fits a 200k window");
8628        assert_eq!(window_advisories(&events), 0);
8629    }
8630
8631    /// Override 400k on a 200k model: clamped back to 200k (no compaction of
8632    /// a history that fits 200k, but the clamp is announced), because a window
8633    /// larger than the real one would reintroduce the provider-side truncation
8634    /// compaction exists to prevent.
8635    #[tokio::test]
8636    async fn context_window_override_above_the_registry_window_is_clamped_and_announced() {
8637        let dir = tempfile::tempdir().unwrap();
8638        let rt = runtime_for(dir.path()).await;
8639        let (script, _seen) = windowed(200_000);
8640        let mut messages = long_history();
8641        let cfg = AssistantConfig {
8642            context_window_override: Some(400_000),
8643            refuse_unadvertised_tools: false,
8644            response_format_validator: None,
8645            delegate_budget: None,
8646            ..cfg()
8647        };
8648        let mut events = Vec::new();
8649        let outcome =
8650            run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
8651        assert_eq!(outcome.status, "success");
8652        assert!(!has_compaction_notice(&messages));
8653        assert_eq!(window_advisories(&events), 1, "the clamp must be visible");
8654    }
8655
8656    #[test]
8657    fn resolve_context_window_clamps_only_upward_against_a_known_window() {
8658        assert_eq!(resolve_context_window(None, 200_000), (200_000, None));
8659        assert_eq!(resolve_context_window(None, 0), (0, None));
8660        assert_eq!(
8661            resolve_context_window(Some(20_000), 200_000),
8662            (20_000, None)
8663        );
8664        let (w, advisory) = resolve_context_window(Some(400_000), 200_000);
8665        assert_eq!(w, 200_000);
8666        assert!(advisory
8667            .unwrap()
8668            .contains("exceeds the model's known window"));
8669        // Unknown registry window: nothing to clamp against, the override
8670        // stands (ignoring it would silently disable the compaction asked for).
8671        assert_eq!(resolve_context_window(Some(400_000), 0), (400_000, None));
8672    }
8673
8674    #[test]
8675    fn the_assistant_loops_compaction_notice_text_is_unchanged() {
8676        // The notice became caller-parameterized so the declarative runner can
8677        // stop promising an `events_query` it cannot call. Every existing
8678        // caller must still emit the SAME bytes — asserted as a literal, so a
8679        // future edit to the other arm cannot quietly reword this one.
8680        assert_eq!(
8681            format_compaction_notice(3, 1234, CompactionRecovery::default()),
8682            "[history compacted: 3 earlier turns removed to fit the context window, \
8683             ~1234 tokens. They are gone from this transcript but the run's event log \
8684             still has them — call `events_query` (e.g. {\"kinds\": [\"action_failed\"], \
8685             \"limit\": 5}) to see what was already tried, rather than assuming you never \
8686             tried it.]"
8687        );
8688        assert_eq!(
8689            format_compaction_notice(3, 1234, CompactionRecovery::EventsQuery),
8690            format_compaction_notice(3, 1234, CompactionRecovery::default()),
8691            "EventsQuery is the default; no caller changes behavior by omitting it"
8692        );
8693
8694        // The honest arm: same parseable shape, no recovery path it cannot offer.
8695        let unrecoverable = format_compaction_notice(3, 1234, CompactionRecovery::Unrecoverable);
8696        assert!(unrecoverable.starts_with(COMPACTION_NOTICE_PREFIX));
8697        assert!(!unrecoverable.contains("events_query"));
8698        assert!(!unrecoverable.contains("event log"));
8699        assert_eq!(
8700            parse_compaction_notice(&Message::System {
8701                content: unrecoverable,
8702            }),
8703            Some((3, 1234)),
8704            "both arms must round-trip through parse_compaction_notice"
8705        );
8706    }
8707
8708    #[test]
8709    fn history_budget_is_the_same_number_the_inline_expression_produced() {
8710        // The fraction moved into a named constant; the arithmetic must not.
8711        // Integer division FIRST (`w / 4 * 3`), which is not the same as
8712        // `w * 3 / 4` for every window, and a window of 0 stays 0 so the
8713        // unknown-window early return keeps its meaning.
8714        for window in [0usize, 1, 3, 5, 4_096, 8_192, 131_072, 200_000, 1_048_576] {
8715            assert_eq!(
8716                history_budget(window),
8717                window / 4 * 3,
8718                "budget changed for window {window}"
8719            );
8720        }
8721        assert_eq!(history_budget(200_000), 150_000);
8722        assert_eq!(history_budget(0), 0);
8723    }
8724
8725    // ---- compaction decides on the provider-reported prompt size ----
8726
8727    /// A history whose chars/4 estimate is small (~6-7k tokens): the reported
8728    /// count, not the estimate, must be what trips compaction.
8729    fn modest_history() -> Vec<Message> {
8730        let body = "x".repeat(2_000);
8731        let mut m = vec![sys("system"), usr("THE ORIGINAL TASK")];
8732        for i in 0..12 {
8733            m.push(asst_call(&format!("c{i}")));
8734            m.push(tool_res(&format!("c{i}"), &body));
8735        }
8736        m
8737    }
8738
8739    /// The live failure: a 150k-window run whose estimate sat under budget
8740    /// while the provider billed 144k, and compaction never fired. A reported
8741    /// prompt size far above the estimate must trigger compaction on the
8742    /// NEXT turn.
8743    #[tokio::test]
8744    async fn reported_prompt_tokens_far_above_the_estimate_trigger_compaction_next_turn() {
8745        let dir = tempfile::tempdir().unwrap();
8746        let rt = runtime_for(dir.path()).await;
8747        let seen = Arc::new(StdMutex::new(Vec::new()));
8748        let script = WindowedScript {
8749            inner: CapturingScript {
8750                turns: vec![
8751                    turn_with_usage(
8752                        "computing",
8753                        json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8754                        190_000,
8755                        10,
8756                    ),
8757                    turn("done", json!([])),
8758                ],
8759                cursor: AtomicUsize::new(0),
8760                seen: Arc::clone(&seen),
8761            },
8762            window: 200_000,
8763        };
8764        let mut messages = modest_history();
8765        let estimate = messages.iter().map(approx_message_tokens).sum::<usize>();
8766        assert!(
8767            estimate < 20_000,
8768            "fixture estimate must sit far under the 150k budget: {estimate}"
8769        );
8770
8771        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8772        assert_eq!(outcome.status, "success");
8773
8774        let reqs = seen.lock().unwrap();
8775        assert_eq!(reqs.len(), 2);
8776        assert!(
8777            !has_compaction_notice(reqs[0].messages.as_ref().unwrap()),
8778            "turn 1 has no report yet and the estimate fits"
8779        );
8780        assert!(
8781            has_compaction_notice(reqs[1].messages.as_ref().unwrap()),
8782            "turn 2 must compact on the 190k the provider reported for turn 1"
8783        );
8784        assert!(has_compaction_notice(&messages));
8785    }
8786
8787    /// No usage report → the estimate alone decides, exactly as before.
8788    #[tokio::test]
8789    async fn no_usage_report_falls_back_to_the_estimate() {
8790        let dir = tempfile::tempdir().unwrap();
8791        let rt = runtime_for(dir.path()).await;
8792        let seen = Arc::new(StdMutex::new(Vec::new()));
8793        let script = WindowedScript {
8794            inner: CapturingScript {
8795                turns: vec![
8796                    turn(
8797                        "computing",
8798                        json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8799                    ),
8800                    turn("done", json!([])),
8801                ],
8802                cursor: AtomicUsize::new(0),
8803                seen: Arc::clone(&seen),
8804            },
8805            window: 200_000,
8806        };
8807        let mut messages = modest_history();
8808        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
8809        assert_eq!(outcome.status, "success");
8810        assert_eq!(seen.lock().unwrap().len(), 2);
8811        assert!(!has_compaction_notice(&messages));
8812    }
8813
8814    /// The reported count is scaled onto the per-message estimates for the
8815    /// drop loop, so one compaction lands under budget; the notice accounts
8816    /// in that same measure and still round-trips.
8817    #[test]
8818    fn measured_compaction_scales_the_drop_to_the_reported_size_and_round_trips() {
8819        let mut m = modest_history();
8820        let len = m.len();
8821        let estimate: usize = m.iter().map(approx_message_tokens).sum();
8822        // ~7k estimated; the provider says 40× that, over a 150k budget.
8823        let measure = PromptMeasure {
8824            fixed_overhead: 0,
8825            reported: Some((estimate * 40, len)),
8826        };
8827        compact_history_measured(&mut m, 200_000, measure);
8828        let notice = m
8829            .iter()
8830            .find(|msg| parse_compaction_notice(msg).is_some())
8831            .expect("must compact on the reported size");
8832        let (turns, tokens) = parse_compaction_notice(notice).unwrap();
8833        assert!(turns > 0);
8834        assert!(
8835            tokens > estimate,
8836            "dropped tokens are accounted in the scaled (provider) measure: {tokens} vs raw estimate {estimate}"
8837        );
8838        // Unscaled, dropping the same turns would have removed at most the
8839        // raw estimate — the scale is what makes one pass sufficient.
8840        let remaining: usize = m.iter().map(approx_message_tokens).sum();
8841        assert!(remaining < estimate);
8842    }
8843
8844    /// The tool definitions are part of every request; a history that fits
8845    /// the budget on its own but not with the tools must compact.
8846    #[test]
8847    fn fixed_overhead_counts_toward_the_budget() {
8848        let mut m = modest_history();
8849        let estimate: usize = m.iter().map(approx_message_tokens).sum();
8850        // Budget is 3/4 of the window: sit just under it on the history alone.
8851        let window = estimate * 4 / 3 + 40;
8852        compact_history_measured(&mut m, window, PromptMeasure::default());
8853        assert!(!has_compaction_notice(&m), "history alone fits");
8854        compact_history_measured(
8855            &mut m,
8856            window,
8857            PromptMeasure {
8858                fixed_overhead: 5_000,
8859                reported: None,
8860            },
8861        );
8862        assert!(has_compaction_notice(&m), "history + tool defs does not");
8863    }
8864
8865    /// A reported count within 25% of the estimate does not rescale the
8866    /// per-message numbers; one further off does.
8867    #[test]
8868    fn reported_count_only_rescales_beyond_a_quarter_off() {
8869        let m = modest_history();
8870        let len = m.len();
8871        let estimate: usize = m.iter().map(approx_message_tokens).sum();
8872        // Within 25%: decide on the reported total, drop by raw estimates.
8873        let mut close = m.clone();
8874        compact_history_measured(
8875            &mut close,
8876            estimate * 4 / 3,
8877            PromptMeasure {
8878                fixed_overhead: 0,
8879                reported: Some((estimate * 11 / 10, len)),
8880            },
8881        );
8882        let (_, close_tokens) = close
8883            .iter()
8884            .find_map(parse_compaction_notice)
8885            .expect("110% of a budget-sized estimate must compact");
8886        // Far off: the same drop is accounted ~20x larger.
8887        let mut far = m.clone();
8888        compact_history_measured(
8889            &mut far,
8890            estimate * 4 / 3,
8891            PromptMeasure {
8892                fixed_overhead: 0,
8893                reported: Some((estimate * 20, len)),
8894            },
8895        );
8896        let (_, far_tokens) = far.iter().find_map(parse_compaction_notice).unwrap();
8897        assert!(
8898            far_tokens > close_tokens * 5,
8899            "{far_tokens} vs {close_tokens}"
8900        );
8901    }
8902
8903    // ---- delegate: loop-intercepted sub-agent ----
8904
8905    /// The parent's config with `delegate` advertised over its own tools.
8906    fn delegate_cfg() -> AssistantConfig {
8907        let mut tools = GeneralExecutor::tool_defs();
8908        tools.push(delegate_tool_def(&tools));
8909        AssistantConfig { tools, ..cfg() }
8910    }
8911
8912    fn delegate_call(params: Value) -> InferenceResult {
8913        turn(
8914            "delegating",
8915            json!([{ "id": "d1", "name": DELEGATE_TOOL, "arguments": params }]),
8916        )
8917    }
8918
8919    fn calc_call() -> InferenceResult {
8920        turn(
8921            "computing",
8922            json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
8923        )
8924    }
8925
8926    fn tool_names(req: &GenerateRequest) -> Vec<String> {
8927        req.tools
8928            .as_deref()
8929            .unwrap_or_default()
8930            .iter()
8931            .filter_map(|d| d.get("name").and_then(Value::as_str))
8932            .map(str::to_string)
8933            .collect()
8934    }
8935
8936    /// The parent's `ToolResult` for the delegate call.
8937    fn delegate_result(messages: &[Message]) -> (String, bool) {
8938        messages
8939            .iter()
8940            .find_map(|m| match m {
8941                Message::ToolResult {
8942                    tool_use_id,
8943                    content,
8944                    provenance,
8945                } if tool_use_id == "d1" => {
8946                    Some((content.clone(), *provenance == Provenance::External))
8947                }
8948                _ => None,
8949            })
8950            .expect("the delegate call must have a tool result")
8951    }
8952
8953    #[test]
8954    fn delegate_tool_def_enumerates_parent_tools_and_excludes_itself() {
8955        let mut tools = GeneralExecutor::tool_defs();
8956        let def = delegate_tool_def(&tools);
8957        assert_eq!(def["name"], DELEGATE_TOOL);
8958        assert_eq!(def["tier"], "read_only");
8959        assert_eq!(def["mutating"], true, "a finished delegation is progress");
8960        assert_eq!(def["parameters"]["required"], json!(["goal"]));
8961        let en = def["parameters"]["properties"]["tools"]["items"]["enum"]
8962            .as_array()
8963            .unwrap()
8964            .clone();
8965        assert!(en.iter().any(|v| v == "calculate"));
8966        assert!(!en.iter().any(|v| v == DELEGATE_TOOL));
8967        // Built over a set that already carries `delegate`: still excluded.
8968        tools.push(def);
8969        let again = delegate_tool_def(&tools);
8970        assert!(!again["parameters"]["properties"]["tools"]["items"]["enum"]
8971            .as_array()
8972            .unwrap()
8973            .iter()
8974            .any(|v| v == DELEGATE_TOOL));
8975        assert!(mutating_tool_names(&tools).contains(DELEGATE_TOOL));
8976    }
8977
8978    #[test]
8979    fn delegate_params_parse_with_defaults_and_cap() {
8980        let r = parse_delegate_params(&json!({"goal": " count "})).unwrap();
8981        assert_eq!(r.goal, "count");
8982        assert_eq!(r.tools, None);
8983        assert_eq!(r.max_turns, DELEGATE_DEFAULT_MAX_TURNS);
8984        let r =
8985            parse_delegate_params(&json!({"goal": "x", "tools": ["calculate"], "max_turns": 500}))
8986                .unwrap();
8987        assert_eq!(r.tools.as_deref(), Some(&["calculate".to_string()][..]));
8988        assert_eq!(r.max_turns, DELEGATE_MAX_TURNS_CAP);
8989        assert!(parse_delegate_params(&json!({"goal": ""})).is_err());
8990        assert!(parse_delegate_params(&json!({"goal": "x", "max_turns": 0})).is_err());
8991        assert!(parse_delegate_params(&json!({"goal": "x", "tools": "calculate"})).is_err());
8992    }
8993
8994    /// The child config is the parent's, minus what a child must not have.
8995    #[test]
8996    fn delegate_child_config_derives_from_the_parent() {
8997        let mut parent = delegate_cfg();
8998        parent.gated_tools = vec!["shell".into()];
8999        parent.context_window_override = Some(20_000);
9000        parent.response_format = Some(car_inference::ResponseFormat::JsonObject);
9001        parent.todos = Some(Arc::new(tokio::sync::Mutex::new(
9002            super::super::todo::TodoList::new(),
9003        )));
9004        let req = parse_delegate_params(&json!({"goal": "g", "tools": ["calculate"]})).unwrap();
9005        let child = delegate_child_config(&parent, &req).unwrap();
9006        assert_eq!(
9007            child
9008                .tools
9009                .iter()
9010                .map(|d| d["name"].as_str().unwrap())
9011                .collect::<Vec<_>>(),
9012            vec!["calculate"]
9013        );
9014        assert!(child.refuse_unadvertised_tools);
9015        assert_eq!(child.max_turns, DELEGATE_DEFAULT_MAX_TURNS);
9016        assert!(child.todos.is_none());
9017        assert!(child.response_format.is_none(), "children answer in prose");
9018        assert_eq!(
9019            child.gated_tools, parent.gated_tools,
9020            "gates inherited whole"
9021        );
9022        assert_eq!(child.context_window_override, Some(20_000));
9023        assert_eq!(child.model, parent.model);
9024        // Default subset = everything delegable, still without `delegate`.
9025        let all = delegate_child_config(
9026            &parent,
9027            &parse_delegate_params(&json!({"goal": "g"})).unwrap(),
9028        )
9029        .unwrap();
9030        let names: Vec<&str> = all
9031            .tools
9032            .iter()
9033            .map(|d| d["name"].as_str().unwrap())
9034            .collect();
9035        assert!(names.contains(&"calculate"));
9036        assert!(!names.contains(&DELEGATE_TOOL));
9037        assert_eq!(names.len(), parent.tools.len() - 1);
9038    }
9039
9040    /// Parent issues `delegate` → the child runs with its OWN history (system
9041    /// prompt + goal, none of the parent's messages), on the parent's tools
9042    /// minus `delegate`, and the parent receives only the child's final text.
9043    #[tokio::test]
9044    async fn delegate_child_runs_with_a_fresh_history_and_returns_only_its_final_text() {
9045        let dir = tempfile::tempdir().unwrap();
9046        let rt = runtime_for(dir.path()).await;
9047        let seen = Arc::new(StdMutex::new(Vec::new()));
9048        let script = CapturingScript {
9049            turns: vec![
9050                delegate_call(json!({"goal": "what is 1+1? reply with the number only"})),
9051                calc_call(),                         // child turn 1
9052                turn("2", json!([])),                // child turn 2: final
9053                turn("The answer is 2.", json!([])), // parent turn 2
9054            ],
9055            cursor: AtomicUsize::new(0),
9056            seen: Arc::clone(&seen),
9057        };
9058        let mut messages = vec![
9059            sys("PARENT SYSTEM PROMPT"),
9060            usr("PARENT TASK: add one and one"),
9061        ];
9062        let mut events = Vec::new();
9063        let outcome = run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |e| {
9064            events.push(e)
9065        })
9066        .await;
9067        assert_eq!(outcome.status, "success");
9068        assert_eq!(outcome.summary, "The answer is 2.");
9069        assert_eq!(outcome.turns, 2, "child turns are not the parent's");
9070
9071        let reqs = seen.lock().unwrap();
9072        assert_eq!(reqs.len(), 4);
9073        let child_first = reqs[1].messages.as_ref().unwrap();
9074        assert_eq!(
9075            child_first.len(),
9076            2,
9077            "exactly system + goal: {child_first:?}"
9078        );
9079        assert!(
9080            matches!(&child_first[0], Message::System { content } if content == "PARENT SYSTEM PROMPT")
9081        );
9082        assert!(
9083            matches!(&child_first[1], Message::User { content } if content.starts_with("what is 1+1?"))
9084        );
9085        assert!(
9086            !serde_json::to_string(child_first)
9087                .unwrap()
9088                .contains("PARENT TASK"),
9089            "nothing from the parent's transcript reaches the child"
9090        );
9091        let child_tools = tool_names(&reqs[1]);
9092        assert!(child_tools.contains(&"calculate".to_string()));
9093        assert!(
9094            !child_tools.contains(&DELEGATE_TOOL.to_string()),
9095            "no nesting"
9096        );
9097        assert!(tool_names(&reqs[0]).contains(&DELEGATE_TOOL.to_string()));
9098        // The parent's second request carries the delegate result and none of
9099        // the child's transcript.
9100        let parent_second = reqs[3].messages.as_ref().unwrap();
9101        assert!(!serde_json::to_string(parent_second)
9102            .unwrap()
9103            .contains("computing"));
9104        drop(reqs);
9105
9106        let (content, external) = delegate_result(&messages);
9107        assert_eq!(content, "2", "only the child's final text comes back");
9108        assert!(!external, "calculate is internal");
9109
9110        // Receipt + events: the delegation is one tool call and one result.
9111        assert!(events
9112            .iter()
9113            .any(|e| matches!(e, AssistantEvent::ToolCall { name, .. } if name == DELEGATE_TOOL)));
9114        assert!(events.iter().any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: true, content, .. } if name == DELEGATE_TOOL && content == "2")));
9115        assert!(events.iter().any(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with("[delegate: what is 1+1?") && t.ends_with("2 turns, ok]"))));
9116        assert!(
9117            !events
9118                .iter()
9119                .any(|e| matches!(e, AssistantEvent::ToolCall { name, .. } if name == "calculate")),
9120            "the child's own tool calls are not forwarded"
9121        );
9122        let receipt = outcome
9123            .tool_receipts
9124            .iter()
9125            .find(|r| r.tool == DELEGATE_TOOL)
9126            .unwrap();
9127        assert!(receipt.ok);
9128        assert_eq!(receipt.call_id.as_deref(), Some("turn_1_call_1"));
9129        assert_eq!(receipt.sequence, Some(1));
9130        assert_eq!(outcome.tools_called, vec![DELEGATE_TOOL.to_string()]);
9131    }
9132
9133    /// The granted subset holds at EXECUTION: a child call to an ungranted
9134    /// tool is refused with an error result, never dispatched.
9135    #[tokio::test]
9136    async fn delegate_child_tool_subset_is_enforced_at_execution() {
9137        let dir = tempfile::tempdir().unwrap();
9138        let rt = runtime_for(dir.path()).await;
9139        let seen = Arc::new(StdMutex::new(Vec::new()));
9140        let script = CapturingScript {
9141            turns: vec![
9142                delegate_call(json!({"goal": "write hi.txt", "tools": ["calculate"]})),
9143                turn(
9144                    "writing",
9145                    json!([{ "id": "w", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
9146                ),
9147                turn("could not write", json!([])),
9148                turn("done", json!([])),
9149            ],
9150            cursor: AtomicUsize::new(0),
9151            seen: Arc::clone(&seen),
9152        };
9153        let mut messages = vec![sys("sys"), usr("task")];
9154        let outcome =
9155            run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9156        assert_eq!(outcome.status, "success");
9157        assert!(
9158            !dir.path().join("hi.txt").exists(),
9159            "the ungranted write must not run"
9160        );
9161        let reqs = seen.lock().unwrap();
9162        assert_eq!(tool_names(&reqs[1]), vec!["calculate".to_string()]);
9163        let child_second = reqs[2].messages.as_ref().unwrap();
9164        let refusal = child_second
9165            .iter()
9166            .find_map(|m| match m {
9167                Message::ToolResult {
9168                    tool_use_id,
9169                    content,
9170                    ..
9171                } if tool_use_id == "w" => Some(content.clone()),
9172                _ => None,
9173            })
9174            .unwrap();
9175        assert!(
9176            refusal.contains("not granted to this delegate"),
9177            "{refusal}"
9178        );
9179        assert!(
9180            refusal.contains("calculate"),
9181            "says what IS allowed: {refusal}"
9182        );
9183    }
9184
9185    /// No nesting: a child that calls `delegate` is refused at execution, and
9186    /// a parent that tries to GRANT `delegate` is refused as an escalation.
9187    #[tokio::test]
9188    async fn delegate_cannot_nest() {
9189        let dir = tempfile::tempdir().unwrap();
9190        let rt = runtime_for(dir.path()).await;
9191        let seen = Arc::new(StdMutex::new(Vec::new()));
9192        let script = CapturingScript {
9193            turns: vec![
9194                delegate_call(json!({"goal": "go deeper"})),
9195                turn(
9196                    "nesting",
9197                    json!([{ "id": "n", "name": DELEGATE_TOOL, "arguments": { "goal": "deeper still" } }]),
9198                ),
9199                turn("could not nest", json!([])),
9200                turn("done", json!([])),
9201            ],
9202            cursor: AtomicUsize::new(0),
9203            seen: Arc::clone(&seen),
9204        };
9205        let mut messages = vec![sys("sys"), usr("task")];
9206        let outcome =
9207            run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9208        assert_eq!(outcome.status, "success");
9209        {
9210            let reqs = seen.lock().unwrap();
9211            assert_eq!(reqs.len(), 4, "the nested call must not spawn a grandchild");
9212            let child_second = reqs[2].messages.as_ref().unwrap();
9213            let refusal = serde_json::to_string(child_second).unwrap();
9214            assert!(
9215                refusal.contains("not granted to this delegate"),
9216                "{refusal}"
9217            );
9218        }
9219
9220        // Granting `delegate` explicitly is an escalation.
9221        let seen = Arc::new(StdMutex::new(Vec::new()));
9222        let script = CapturingScript {
9223            turns: vec![
9224                delegate_call(json!({"goal": "go deeper", "tools": [DELEGATE_TOOL]})),
9225                turn("done", json!([])),
9226            ],
9227            cursor: AtomicUsize::new(0),
9228            seen: Arc::clone(&seen),
9229        };
9230        let mut messages = vec![sys("sys"), usr("task")];
9231        let mut events = Vec::new();
9232        run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |e| {
9233            events.push(e)
9234        })
9235        .await;
9236        assert_eq!(seen.lock().unwrap().len(), 2, "no child ran");
9237        let (content, _) = delegate_result(&messages);
9238        assert!(
9239            content.contains("privilege escalation rejected"),
9240            "{content}"
9241        );
9242        assert!(content.contains("cannot delegate further"), "{content}");
9243        assert!(events.iter().any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: false, .. } if name == DELEGATE_TOOL)));
9244    }
9245
9246    /// A name outside the parent's set is refused before any child runs.
9247    #[tokio::test]
9248    async fn delegate_escalation_is_refused_without_spawning() {
9249        let dir = tempfile::tempdir().unwrap();
9250        let rt = runtime_for(dir.path()).await;
9251        let seen = Arc::new(StdMutex::new(Vec::new()));
9252        let script = CapturingScript {
9253            turns: vec![
9254                delegate_call(json!({"goal": "x", "tools": ["calculate", "launch_missiles"]})),
9255                turn("done", json!([])),
9256            ],
9257            cursor: AtomicUsize::new(0),
9258            seen: Arc::clone(&seen),
9259        };
9260        let mut messages = vec![sys("sys"), usr("task")];
9261        let outcome =
9262            run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9263        assert_eq!(outcome.status, "success");
9264        assert_eq!(seen.lock().unwrap().len(), 2);
9265        let (content, _) = delegate_result(&messages);
9266        assert!(content.contains("launch_missiles"), "{content}");
9267        let receipt = outcome
9268            .tool_receipts
9269            .iter()
9270            .find(|r| r.tool == DELEGATE_TOOL)
9271            .unwrap();
9272        assert!(!receipt.ok);
9273        assert!(
9274            outcome.tools_called.is_empty(),
9275            "a refused delegation is not progress"
9276        );
9277    }
9278
9279    /// A child that runs out of turns comes back as an ERROR result carrying
9280    /// the reason — the parent must not read an unfinished delegation as an
9281    /// answer.
9282    #[tokio::test]
9283    async fn delegate_turn_cap_is_an_error_result() {
9284        let dir = tempfile::tempdir().unwrap();
9285        let rt = runtime_for(dir.path()).await;
9286        let seen = Arc::new(StdMutex::new(Vec::new()));
9287        let script = CapturingScript {
9288            turns: vec![
9289                delegate_call(json!({"goal": "keep computing", "max_turns": 1})),
9290                calc_call(), // child turn 1 = its whole budget
9291                turn("done", json!([])),
9292            ],
9293            cursor: AtomicUsize::new(0),
9294            seen: Arc::clone(&seen),
9295        };
9296        let mut messages = vec![sys("sys"), usr("task")];
9297        let mut events = Vec::new();
9298        let outcome = run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |e| {
9299            events.push(e)
9300        })
9301        .await;
9302        assert_eq!(outcome.status, "success");
9303        assert_eq!(seen.lock().unwrap().len(), 3);
9304        let (content, _) = delegate_result(&messages);
9305        assert!(content.contains("did not finish"), "{content}");
9306        assert!(content.contains("status: max_turns"), "{content}");
9307        assert!(events
9308            .iter()
9309            .any(|e| matches!(e, AssistantEvent::Text(t) if t.ends_with("1 turns, error]"))));
9310        assert!(events.iter().any(|e| matches!(e, AssistantEvent::ToolResult { name, ok: false, .. } if name == DELEGATE_TOOL)));
9311        assert!(outcome.tools_called.is_empty());
9312    }
9313
9314    /// The child's final text is capped like any other observation.
9315    #[tokio::test]
9316    async fn delegate_summary_is_capped() {
9317        let dir = tempfile::tempdir().unwrap();
9318        let rt = runtime_for(dir.path()).await;
9319        let seen = Arc::new(StdMutex::new(Vec::new()));
9320        let long = "y".repeat(OBSERVATION_CAP * 3);
9321        let script = CapturingScript {
9322            turns: vec![
9323                delegate_call(json!({"goal": "dump"})),
9324                turn(&long, json!([])),
9325                turn("done", json!([])),
9326            ],
9327            cursor: AtomicUsize::new(0),
9328            seen: Arc::clone(&seen),
9329        };
9330        let mut messages = vec![sys("sys"), usr("task")];
9331        run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9332        let (content, _) = delegate_result(&messages);
9333        assert!(content.len() < long.len());
9334        assert!(
9335            content.contains("bytes elided"),
9336            "the cap must say what it dropped"
9337        );
9338    }
9339
9340    /// Approval inheritance by construction: a read-only parent (gated
9341    /// write/shell, no approver) cannot get a write done through a child —
9342    /// the child's write is denied by the GATE, not by the subset.
9343    #[tokio::test]
9344    async fn delegate_child_inherits_the_parents_approval_gate() {
9345        let dir = tempfile::tempdir().unwrap();
9346        let rt = runtime_for(dir.path()).await;
9347        let seen = Arc::new(StdMutex::new(Vec::new()));
9348        let script = CapturingScript {
9349            turns: vec![
9350                delegate_call(
9351                    json!({"goal": "write hi.txt", "tools": ["write_file", "calculate"]}),
9352                ),
9353                turn(
9354                    "writing",
9355                    json!([{ "id": "w", "name": "write_file", "arguments": { "path": "hi.txt", "content": "hello" } }]),
9356                ),
9357                turn("denied", json!([])),
9358                turn("done", json!([])),
9359            ],
9360            cursor: AtomicUsize::new(0),
9361            seen: Arc::clone(&seen),
9362        };
9363        let mut cfg = delegate_cfg();
9364        cfg.gated_tools = vec!["write_file".into(), "edit_file".into(), "shell".into()];
9365        let mut messages = vec![sys("sys"), usr("task")];
9366        let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
9367        assert_eq!(outcome.status, "success");
9368        assert!(!dir.path().join("hi.txt").exists());
9369        let reqs = seen.lock().unwrap();
9370        // The subset DID grant write_file — it is the gate that says no.
9371        assert!(tool_names(&reqs[1]).contains(&"write_file".to_string()));
9372        let child_second = serde_json::to_string(reqs[2].messages.as_ref().unwrap()).unwrap();
9373        assert!(child_second.contains("needs approval"), "{child_second}");
9374        assert!(!child_second.contains("not granted"), "{child_second}");
9375    }
9376
9377    /// A run that never advertised `delegate` treats a `delegate` call as an
9378    /// unknown tool: no child, no free sub-agent.
9379    #[tokio::test]
9380    async fn delegate_is_not_intercepted_unless_advertised() {
9381        let dir = tempfile::tempdir().unwrap();
9382        let rt = runtime_for(dir.path()).await;
9383        let seen = Arc::new(StdMutex::new(Vec::new()));
9384        let script = CapturingScript {
9385            turns: vec![delegate_call(json!({"goal": "x"})), turn("done", json!([]))],
9386            cursor: AtomicUsize::new(0),
9387            seen: Arc::clone(&seen),
9388        };
9389        let mut messages = vec![sys("sys"), usr("task")];
9390        run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
9391        assert_eq!(seen.lock().unwrap().len(), 2, "no child ran");
9392        let (content, _) = delegate_result(&messages);
9393        assert!(!content.is_empty());
9394        assert!(
9395            !content.contains("did not finish"),
9396            "not a delegation at all: {content}"
9397        );
9398    }
9399
9400    // ---- round 4: grounding, budget, schema enforcement, repair failure ----
9401
9402    /// A parent that delegates "run the tests" and reports the result is NOT
9403    /// flagged ungrounded: the child's own receipts are merged (tagged) into
9404    /// the parent's list, so the claim check sees the child's shell call.
9405    #[tokio::test]
9406    async fn delegate_child_receipts_ground_the_parents_claims() {
9407        let dir = tempfile::tempdir().unwrap();
9408        let rt = runtime_for(dir.path()).await;
9409        let seen = Arc::new(StdMutex::new(Vec::new()));
9410        let script = CapturingScript {
9411            turns: vec![
9412                delegate_call(json!({"goal": "run the test suite and report", "tools": ["shell"]})),
9413                turn(
9414                    "running",
9415                    json!([{ "id": "s", "name": "shell", "arguments": { "command": "echo cargo test ok" } }]),
9416                ),
9417                turn("The suite ran.", json!([])),
9418                turn("I ran the tests and they passed.", json!([])),
9419            ],
9420            cursor: AtomicUsize::new(0),
9421            seen: Arc::clone(&seen),
9422        };
9423        let mut messages = vec![sys("sys"), usr("run the tests")];
9424        let outcome =
9425            run_assistant_loop(&script, &rt, &delegate_cfg(), &mut messages, |_| {}).await;
9426        // Without the merge this is status "error" after two redrives (the
9427        // script would be exhausted); with it the claim is grounded.
9428        assert_eq!(outcome.status, "success", "{}", outcome.summary);
9429        assert_eq!(outcome.summary, "I ran the tests and they passed.");
9430        let child_shell = outcome
9431            .tool_receipts
9432            .iter()
9433            .find(|r| r.tool == "shell")
9434            .expect("the child's shell receipt must be in the parent's list");
9435        assert!(child_shell.ok);
9436        // The delegate's own receipt is still there, untagged.
9437        let del = outcome
9438            .tool_receipts
9439            .iter()
9440            .find(|r| r.tool == DELEGATE_TOOL)
9441            .unwrap();
9442        assert!(del.via.is_none());
9443        // Tagged with the LOOP's call id for the delegate call, not the id
9444        // the provider sent: provider ids can repeat across turns, which is
9445        // why the loop generates its own, and a tag that named the provider's
9446        // would not identify the parent call it came from.
9447        assert_eq!(
9448            child_shell.via.as_deref(),
9449            Some(format!("delegate:{}", del.call_id.as_deref().unwrap()).as_str())
9450        );
9451        assert!(
9452            ungrounded_summary_claims(&outcome.summary, &outcome.tool_receipts).is_empty(),
9453            "the merged shell receipt grounds the tests-passed claim"
9454        );
9455        // The child numbers its own turns from one, so its first receipt would
9456        // be `turn_1_call_1` — the parent's own first call id. Namespacing
9457        // under the parent call keeps every id in the list distinct, which is
9458        // what a host correlating a streamed `tool_result` row relies on.
9459        assert_eq!(
9460            child_shell.call_id.as_deref(),
9461            Some(format!("{}/turn_1_call_1", del.call_id.as_deref().unwrap()).as_str())
9462        );
9463        let mut ids: Vec<&str> = outcome
9464            .tool_receipts
9465            .iter()
9466            .filter_map(|r| r.call_id.as_deref())
9467            .collect();
9468        let total = ids.len();
9469        assert!(
9470            total >= 2,
9471            "the parent's delegate call and the child's shell"
9472        );
9473        ids.sort_unstable();
9474        ids.dedup();
9475        assert_eq!(ids.len(), total, "no two receipts may share a call id");
9476    }
9477
9478    /// The run-level budget: a delegate call past `max_delegations` is an
9479    /// error result and no child runs.
9480    #[tokio::test]
9481    async fn delegate_budget_caps_delegations() {
9482        let dir = tempfile::tempdir().unwrap();
9483        let rt = runtime_for(dir.path()).await;
9484        let seen = Arc::new(StdMutex::new(Vec::new()));
9485        let script = CapturingScript {
9486            turns: vec![
9487                delegate_call(json!({"goal": "first"})),
9488                turn("one", json!([])), // child 1
9489                delegate_call(json!({"goal": "second"})),
9490                // No second child: the budget refusal is synchronous.
9491                turn("done", json!([])),
9492            ],
9493            cursor: AtomicUsize::new(0),
9494            seen: Arc::clone(&seen),
9495        };
9496        let mut cfg = delegate_cfg();
9497        cfg.delegate_budget = Some(DelegateBudget {
9498            max_delegations: 1,
9499            max_child_turns: 300,
9500        });
9501        let mut messages = vec![sys("sys"), usr("task")];
9502        let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
9503        assert_eq!(outcome.status, "success");
9504        assert_eq!(seen.lock().unwrap().len(), 4);
9505        let refusals: Vec<&AssistantToolReceipt> = outcome
9506            .tool_receipts
9507            .iter()
9508            .filter(|r| r.tool == DELEGATE_TOOL && !r.ok)
9509            .collect();
9510        assert_eq!(refusals.len(), 1);
9511        let refusal_text = messages
9512            .iter()
9513            .find_map(|m| match m {
9514                Message::ToolResult { content, .. } if content.contains("budget exhausted") => {
9515                    Some(content.clone())
9516                }
9517                _ => None,
9518            })
9519            .expect("the refusal must reach the model");
9520        assert!(refusal_text.contains("1 delegations"), "{refusal_text}");
9521    }
9522
9523    /// The other half of the budget: cumulative child turns.
9524    #[tokio::test]
9525    async fn delegate_budget_caps_cumulative_child_turns() {
9526        let dir = tempfile::tempdir().unwrap();
9527        let rt = runtime_for(dir.path()).await;
9528        let seen = Arc::new(StdMutex::new(Vec::new()));
9529        let script = CapturingScript {
9530            turns: vec![
9531                delegate_call(json!({"goal": "first"})),
9532                calc_call(),            // child 1 turn 1
9533                turn("one", json!([])), // child 1 turn 2
9534                delegate_call(json!({"goal": "second"})),
9535                turn("done", json!([])),
9536            ],
9537            cursor: AtomicUsize::new(0),
9538            seen: Arc::clone(&seen),
9539        };
9540        let mut cfg = delegate_cfg();
9541        cfg.delegate_budget = Some(DelegateBudget {
9542            max_delegations: 20,
9543            max_child_turns: 2,
9544        });
9545        let mut messages = vec![sys("sys"), usr("task")];
9546        let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
9547        assert_eq!(outcome.status, "success");
9548        assert_eq!(seen.lock().unwrap().len(), 5, "no second child spawned");
9549        assert!(
9550            messages.iter().any(|m| matches!(m, Message::ToolResult { content, .. } if content.contains("2 child turns"))),
9551            "the refusal names the spent turn budget"
9552        );
9553    }
9554
9555    /// Item 7: with a caller-supplied schema validator, valid JSON of the
9556    /// WRONG shape triggers the repair (the schema-worded nudge), and the
9557    /// conforming repaired answer passes.
9558    #[tokio::test]
9559    async fn json_schema_shape_mismatch_triggers_the_repair() {
9560        let dir = tempfile::tempdir().unwrap();
9561        let rt = runtime_for(dir.path()).await;
9562        let seen = Arc::new(StdMutex::new(Vec::new()));
9563        let script = CapturingScript {
9564            turns: vec![
9565                turn(r#"{"nope": 1}"#, json!([])),
9566                turn(r#"{"legs": []}"#, json!([])),
9567            ],
9568            cursor: AtomicUsize::new(0),
9569            seen: Arc::clone(&seen),
9570        };
9571        let mut cfg = cfg();
9572        cfg.response_format = Some(car_inference::ResponseFormat::JsonSchema {
9573            schema: json!({"type": "object", "required": ["legs"]}),
9574            strict: false,
9575            name: None,
9576        });
9577        cfg.response_format_validator = Some(Arc::new(|v| v.get("legs").is_some()));
9578        let mut messages = vec![sys("sys"), usr("plan the flight, answer as JSON")];
9579        let mut events = Vec::new();
9580        let outcome =
9581            run_assistant_loop(&script, &rt, &cfg, &mut messages, |e| events.push(e)).await;
9582        assert_eq!(outcome.status, "success");
9583        assert_eq!(outcome.summary, r#"{"legs": []}"#);
9584        let reqs = seen.lock().unwrap();
9585        assert_eq!(reqs.len(), 2, "valid-but-wrong-shape JSON must be repaired");
9586        assert!(reqs[1].tools.is_none());
9587        let nudge = reqs[1]
9588            .messages
9589            .as_ref()
9590            .unwrap()
9591            .last()
9592            .and_then(|m| match m {
9593                Message::User { content } => Some(content.clone()),
9594                _ => None,
9595            })
9596            .unwrap();
9597        assert!(
9598            nudge.contains("JSON Schema"),
9599            "schema-worded, not 'object': {nudge}"
9600        );
9601        assert_eq!(repair_notices(&events), (1, 0));
9602    }
9603
9604    /// Item 4: a repair call that ERRORS (an Anthropic-protocol model
9605    /// rejecting response_format) keeps the DRAFT as the answer — success,
9606    /// with a visible failure notice and no dangling nudge in the transcript.
9607    #[tokio::test]
9608    async fn a_failed_repair_call_keeps_the_draft_answer() {
9609        let dir = tempfile::tempdir().unwrap();
9610        let rt = runtime_for(dir.path()).await;
9611        let seen = Arc::new(StdMutex::new(Vec::new()));
9612        let script = CapturingScript {
9613            // One scripted turn: the repair call hits "script exhausted".
9614            turns: vec![turn("The answer is 2, not JSON.", json!([]))],
9615            cursor: AtomicUsize::new(0),
9616            seen: Arc::clone(&seen),
9617        };
9618        let mut messages = vec![sys("sys"), usr("answer as JSON")];
9619        let mut events = Vec::new();
9620        let outcome = run_assistant_loop(&script, &rt, &json_object_cfg(), &mut messages, |e| {
9621            events.push(e)
9622        })
9623        .await;
9624        assert_eq!(outcome.status, "success", "the draft is still an answer");
9625        assert_eq!(outcome.summary, "The answer is 2, not JSON.");
9626        assert!(
9627            events.iter().any(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with(FORMAT_REPAIR_FAILED_PREFIX))),
9628            "the failure must be visible"
9629        );
9630        assert!(
9631            matches!(messages.last(), Some(Message::Assistant { content, .. }) if content == "The answer is 2, not JSON."),
9632            "the transcript ends on the draft answer, not a dangling nudge: {:?}",
9633            messages.last()
9634        );
9635    }
9636
9637    // ---- the out-of-the-box peg: typed `auth_required` refusals ----
9638
9639    /// Fails every generation with one typed error. `generate` panics on
9640    /// purpose: if a call site ever slips back to the string seam, the test
9641    /// says so instead of quietly asserting the old behaviour.
9642    struct TypedFailure(AssistantGenerateError);
9643
9644    #[async_trait]
9645    impl TurnGenerator for TypedFailure {
9646        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
9647            panic!("the assistant loop must generate through the typed seam")
9648        }
9649
9650        async fn generate_assistant(
9651            &self,
9652            _req: GenerateRequest,
9653        ) -> Result<InferenceResult, AssistantGenerateError> {
9654            Err(self.0.clone())
9655        }
9656    }
9657
9658    struct ThreeReceiptsThenTransient(AtomicUsize);
9659
9660    #[async_trait]
9661    impl TurnGenerator for ThreeReceiptsThenTransient {
9662        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
9663            panic!("the assistant loop must preserve the typed transient seam")
9664        }
9665
9666        async fn generate_assistant(
9667            &self,
9668            _req: GenerateRequest,
9669        ) -> Result<InferenceResult, AssistantGenerateError> {
9670            let turn_index = self.0.fetch_add(1, Ordering::SeqCst);
9671            if turn_index < 3 {
9672                return Ok(turn(
9673                    "working",
9674                    json!([{
9675                        "id": format!("calc-{turn_index}"),
9676                        "name": "calculate",
9677                        "arguments": {"expression": format!("1+{turn_index}")}
9678                    }]),
9679                ));
9680            }
9681            Err(AssistantGenerateError::from(InferenceError::Transient {
9682                status: Some(503),
9683                message: "provider unavailable after retries".into(),
9684            }))
9685        }
9686    }
9687
9688    #[derive(Default)]
9689    struct DiscardJsonEvents;
9690
9691    impl super::super::do_json::EventSink for DiscardJsonEvents {
9692        fn emit(&self, _event: Value) {}
9693    }
9694
9695    /// GH#1491: a provider failure after useful work must not erase that work.
9696    /// This drives the real loop through three tool turns, fails the fourth
9697    /// generation with the typed remote transient, then checks the public
9698    /// `car.do/1` terminal document rather than a mirror of its fields.
9699    #[tokio::test]
9700    async fn transient_failure_preserves_completed_turns_receipts_and_typed_cause() {
9701        let dir = tempfile::tempdir().unwrap();
9702        let rt = runtime_for(dir.path()).await;
9703        let mut messages = vec![sys("sys"), usr("do several steps")];
9704        let outcome = run_assistant_loop(
9705            &ThreeReceiptsThenTransient(AtomicUsize::new(0)),
9706            &rt,
9707            &cfg(),
9708            &mut messages,
9709            |_| {},
9710        )
9711        .await;
9712
9713        assert_eq!(outcome.status, "error");
9714        assert_eq!(outcome.turns, 4, "the failed attempt was consumed");
9715        assert_eq!(outcome.turns_completed, 3);
9716        assert_eq!(outcome.tool_receipts.len(), 3);
9717        assert_eq!(
9718            outcome.failure_cause,
9719            Some(AssistantFailureCause::TransientInference { status: Some(503) })
9720        );
9721
9722        let emitter = super::super::do_json::JsonEmitter::new(
9723            super::super::do_json::SandboxPosture {
9724                sandboxed: false,
9725                image: None,
9726                tier: "ReadOnly".into(),
9727                root: dir.path().display().to_string(),
9728                mount: None,
9729                fallback_notice: None,
9730            },
9731            Arc::new(DiscardJsonEvents),
9732        );
9733        let document = emitter.finish(&outcome, None);
9734        assert_eq!(document["status"], "error");
9735        assert_eq!(document["turns_completed"], 3);
9736        assert_eq!(document["receipts"]["total"], 3);
9737        assert_eq!(document["receipts"]["sample"].as_array().unwrap().len(), 3);
9738        assert_eq!(document["failure"]["cause"], "transient_inference");
9739        assert_eq!(document["failure"]["status"], 503);
9740    }
9741
9742    /// The real signed-out error car-inference builds (`remote.rs`), converted
9743    /// through the real `From` — not a hand-made enum value, so a producer
9744    /// reword or a mapping change is what fails these tests.
9745    fn parslee_signed_out() -> AssistantGenerateError {
9746        AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9747            provider: "parslee".into(),
9748            model: "parslee/advisor".into(),
9749            reason: car_inference::CredentialFailure::SignedOut,
9750            detail: "no account is signed in. Run `car auth login`".into(),
9751        })
9752    }
9753
9754    fn parslee_no_workspace() -> AssistantGenerateError {
9755        AssistantGenerateError::from(InferenceError::WorkspaceRequired {
9756            provider: "Parslee".into(),
9757            detail: "finish setting up at https://parslee.ai, then try again".into(),
9758        })
9759    }
9760
9761    fn auth_required_events(events: &[AssistantEvent]) -> Vec<(AuthRequiredReason, String)> {
9762        events
9763            .iter()
9764            .filter_map(|e| match e {
9765                AssistantEvent::AuthRequired { reason, message } => {
9766                    Some((*reason, message.clone()))
9767                }
9768                _ => None,
9769            })
9770            .collect()
9771    }
9772
9773    /// The whole point of the peg, at the turn level: signed out, the agent
9774    /// asks the person to sign in. It does NOT report a transport error, and —
9775    /// because nothing served the turn — it does not claim a serving model.
9776    ///
9777    /// Before this, the same input produced `Error("inference failed: no
9778    /// credential for proprietary provider 'parslee' …")`, which a host could
9779    /// only render as a red row.
9780    #[tokio::test]
9781    async fn a_signed_out_parslee_turn_ends_with_auth_required_not_an_error() {
9782        let dir = tempfile::tempdir().unwrap();
9783        let rt = runtime_for(dir.path()).await;
9784        let mut messages = vec![sys("sys"), usr("hello")];
9785        let mut events = Vec::new();
9786        let outcome = run_assistant_loop(
9787            &TypedFailure(parslee_signed_out()),
9788            &rt,
9789            &cfg(),
9790            &mut messages,
9791            |e| events.push(e),
9792        )
9793        .await;
9794
9795        assert_eq!(outcome.status, "auth_required");
9796        assert_eq!(outcome.auth_required, Some(AuthRequiredReason::SignedOut));
9797        let emitted = auth_required_events(&events);
9798        assert_eq!(emitted.len(), 1, "exactly one terminal refusal");
9799        assert_eq!(emitted[0].0, AuthRequiredReason::SignedOut);
9800        assert_eq!(emitted[0].1, AUTH_REQUIRED_SIGNED_OUT_MESSAGE);
9801        assert!(
9802            emitted[0].1.contains(
9803                "New to Parslee? Create your account at parslee.ai first, then come back \
9804                 and sign in."
9805            ),
9806            "the interim sign-up path must be named verbatim: {}",
9807            emitted[0].1
9808        );
9809        assert_eq!(
9810            outcome.summary, emitted[0].1,
9811            "the outcome summary and the event must say the same thing"
9812        );
9813        assert!(
9814            !events.iter().any(|e| matches!(e, AssistantEvent::Error(_))),
9815            "an auth refusal is not also an error"
9816        );
9817        assert!(
9818            !events
9819                .iter()
9820                .any(|e| matches!(e, AssistantEvent::ModelServed { .. })),
9821            "nothing served this turn"
9822        );
9823        assert!(outcome.models_served.is_empty());
9824    }
9825
9826    /// Signed in, no workspace: a different remedy, so a different reason. The
9827    /// sign-in button would lead straight back here.
9828    #[tokio::test]
9829    async fn a_no_workspace_turn_ends_with_the_no_workspace_reason() {
9830        let dir = tempfile::tempdir().unwrap();
9831        let rt = runtime_for(dir.path()).await;
9832        let mut messages = vec![sys("sys"), usr("hello")];
9833        let mut events = Vec::new();
9834        let outcome = run_assistant_loop(
9835            &TypedFailure(parslee_no_workspace()),
9836            &rt,
9837            &cfg(),
9838            &mut messages,
9839            |e| events.push(e),
9840        )
9841        .await;
9842
9843        assert_eq!(outcome.status, "auth_required");
9844        assert_eq!(outcome.auth_required, Some(AuthRequiredReason::NoWorkspace));
9845        let emitted = auth_required_events(&events);
9846        assert_eq!(emitted.len(), 1);
9847        assert_eq!(emitted[0].1, AUTH_REQUIRED_NO_WORKSPACE_MESSAGE);
9848        assert!(
9849            emitted[0].1.contains("parslee.ai"),
9850            "the remedy is the web step: {}",
9851            emitted[0].1
9852        );
9853        assert!(
9854            !emitted[0]
9855                .1
9856                .to_ascii_lowercase()
9857                .contains("sign in to continue"),
9858            "a signed-in person must not be told to sign in: {}",
9859            emitted[0].1
9860        );
9861    }
9862
9863    /// The wire spellings hosts branch on.
9864    #[test]
9865    fn auth_required_reasons_have_stable_wire_spellings() {
9866        assert_eq!(AuthRequiredReason::SignedOut.as_str(), "signed_out");
9867        assert_eq!(AuthRequiredReason::Expired.as_str(), "expired");
9868        assert_eq!(AuthRequiredReason::NoWorkspace.as_str(), "no_workspace");
9869    }
9870
9871    /// The owner approved these bytes; a host pins them. Equality, not
9872    /// containment, so a stray reword fails here rather than in a fixture
9873    /// somebody has to go and re-record.
9874    #[test]
9875    fn auth_required_messages_are_the_approved_copy() {
9876        assert_eq!(
9877            AuthRequiredReason::SignedOut.remedy(),
9878            "Parslee Core runs on your Parslee account. Sign in to continue. New to Parslee? \
9879             Create your account at parslee.ai first, then come back and sign in."
9880        );
9881        assert_eq!(
9882            AuthRequiredReason::Expired.remedy(),
9883            "Your Parslee sign-in has expired. Sign in again to continue."
9884        );
9885        assert_eq!(
9886            AuthRequiredReason::NoWorkspace.remedy(),
9887            "Your Parslee account has no workspace yet. Finish setting up at parslee.ai, then \
9888             try again."
9889        );
9890        // Nothing here promises a balance, a grant, or an account-free path —
9891        // and the equalities above are what keep it that way. A byte-exact
9892        // assertion against approved copy already forbids every phrase a
9893        // separate banned-word list could name, so the list would be a second,
9894        // weaker statement of the same rule (and would put the very strings it
9895        // forbids into the repository).
9896    }
9897
9898    /// `expired` is RESERVED, and this test says exactly that much.
9899    ///
9900    /// It asserts the MAPPING from a constructed typed value, not that any
9901    /// production path emits one — nothing does today, because a stale
9902    /// credential record still resolves and Parslee's 401 comes back untyped.
9903    /// Typing those sites is separate work; this keeps the mapping honest and
9904    /// in place for it.
9905    #[test]
9906    fn the_expired_reason_is_reserved_and_maps_from_a_constructed_value() {
9907        let expired = AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9908            provider: "parslee".into(),
9909            model: "parslee/advisor".into(),
9910            reason: car_inference::CredentialFailure::Expired {
9911                expires_at: 1_234_567,
9912            },
9913            detail: "the Parslee token expired".into(),
9914        });
9915        assert_eq!(
9916            auth_required_reason(&expired),
9917            Some(AuthRequiredReason::Expired)
9918        );
9919    }
9920
9921    /// Everything that is not a Parslee account problem the person can fix
9922    /// keeps today's `error`. Each of these would be wrong advice on a sign-in
9923    /// card: a locked keychain does not reopen on sign-in, a provider key is
9924    /// not a Parslee session, a retryable race is not a person's problem, and
9925    /// another provider's sign-out is not repaired by signing in to Parslee.
9926    #[tokio::test]
9927    async fn non_account_failures_keep_todays_error_event() {
9928        let cases: Vec<(&str, AssistantGenerateError)> = vec![
9929            (
9930                "store unreadable",
9931                AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9932                    provider: "parslee".into(),
9933                    model: "parslee/advisor".into(),
9934                    reason: car_inference::CredentialFailure::StoreUnreadable,
9935                    detail: "the credential store could not be read".into(),
9936                }),
9937            ),
9938            (
9939                "env var missing",
9940                AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9941                    provider: "openai".into(),
9942                    model: "openai/gpt-5.6".into(),
9943                    reason: car_inference::CredentialFailure::EnvVarMissing {
9944                        env_var: "OPENAI_API_KEY".into(),
9945                    },
9946                    detail: "set OPENAI_API_KEY".into(),
9947                }),
9948            ),
9949            (
9950                "race retryable",
9951                AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9952                    provider: "parslee".into(),
9953                    model: "parslee/advisor".into(),
9954                    reason: car_inference::CredentialFailure::RaceRetryable,
9955                    detail: "credential appeared on re-read".into(),
9956                }),
9957            ),
9958            (
9959                "another provider signed out",
9960                AssistantGenerateError::from(InferenceError::CredentialUnavailable {
9961                    provider: "anthropic".into(),
9962                    model: "anthropic/claude-haiku-4-5:latest".into(),
9963                    reason: car_inference::CredentialFailure::SignedOut,
9964                    detail: "no account is signed in".into(),
9965                }),
9966            ),
9967            (
9968                "provider account 401",
9969                AssistantGenerateError::from(InferenceError::ProviderAccount {
9970                    provider: "parslee".into(),
9971                    status: 401,
9972                    message: "Authentication required".into(),
9973                }),
9974            ),
9975            (
9976                "another provider's workspace gap",
9977                AssistantGenerateError::from(InferenceError::WorkspaceRequired {
9978                    provider: "someoneelse".into(),
9979                    detail: "not our account".into(),
9980                }),
9981            ),
9982            (
9983                // Today's UNTYPED Parslee 401 text, which is prose and must
9984                // stay an error until those sites are typed.
9985                "untyped parslee 401",
9986                AssistantGenerateError::from(InferenceError::InferenceFailed(
9987                    "Parslee org lookup failed: HTTP 401 Unauthorized: Authentication required"
9988                        .into(),
9989                )),
9990            ),
9991        ];
9992
9993        for (label, error) in cases {
9994            assert_eq!(auth_required_reason(&error), None, "{label}");
9995
9996            let dir = tempfile::tempdir().unwrap();
9997            let rt = runtime_for(dir.path()).await;
9998            let mut messages = vec![sys("sys"), usr("hello")];
9999            let mut events = Vec::new();
10000            let outcome =
10001                run_assistant_loop(&TypedFailure(error), &rt, &cfg(), &mut messages, |e| {
10002                    events.push(e)
10003                })
10004                .await;
10005
10006            assert_eq!(outcome.status, "error", "{label}");
10007            assert_eq!(outcome.auth_required, None, "{label}");
10008            assert!(
10009                events.iter().any(|e| matches!(e, AssistantEvent::Error(_))),
10010                "{label} must still report an error"
10011            );
10012            assert!(
10013                auth_required_events(&events).is_empty(),
10014                "{label} must not offer a sign-in"
10015            );
10016        }
10017    }
10018
10019    /// Succeeds once, then fails typed — the shape of an auth failure that
10020    /// lands on the response-format REPAIR call.
10021    struct DraftThenTypedFailure {
10022        calls: AtomicUsize,
10023        error: AssistantGenerateError,
10024    }
10025
10026    #[async_trait]
10027    impl TurnGenerator for DraftThenTypedFailure {
10028        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
10029            panic!("the assistant loop must generate through the typed seam")
10030        }
10031
10032        async fn generate_assistant(
10033            &self,
10034            _req: GenerateRequest,
10035        ) -> Result<InferenceResult, AssistantGenerateError> {
10036            if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
10037                return Ok(turn("The answer is 2, not JSON.", json!([])));
10038            }
10039            Err(self.error.clone())
10040        }
10041    }
10042
10043    /// An auth failure on the REPAIR call keeps the documented behaviour: the
10044    /// draft answer is returned with a visible notice.
10045    ///
10046    /// Deliberately not an `auth_required`. The primary call already succeeded
10047    /// and its `model_served` has been sent; retracting a real answer to show a
10048    /// sign-in card would throw away work the model did, and contradict
10049    /// `docs/car-do-json.md`. The NEXT turn produces the refusal anyway.
10050    #[tokio::test]
10051    async fn an_auth_failure_on_the_repair_turn_keeps_the_draft_answer() {
10052        let dir = tempfile::tempdir().unwrap();
10053        let rt = runtime_for(dir.path()).await;
10054        let generator = DraftThenTypedFailure {
10055            calls: AtomicUsize::new(0),
10056            error: parslee_signed_out(),
10057        };
10058        let mut messages = vec![sys("sys"), usr("answer as JSON")];
10059        let mut events = Vec::new();
10060        let outcome = run_assistant_loop(&generator, &rt, &json_object_cfg(), &mut messages, |e| {
10061            events.push(e)
10062        })
10063        .await;
10064
10065        assert_eq!(
10066            generator.calls.load(Ordering::SeqCst),
10067            2,
10068            "primary + repair"
10069        );
10070        assert_eq!(outcome.status, "success", "the draft is still an answer");
10071        assert_eq!(outcome.summary, "The answer is 2, not JSON.");
10072        assert_eq!(outcome.auth_required, None);
10073        assert!(
10074            auth_required_events(&events).is_empty(),
10075            "the repair turn does not refuse"
10076        );
10077        assert!(
10078            events.iter().any(|e| matches!(e, AssistantEvent::Text(t) if t.starts_with(FORMAT_REPAIR_FAILED_PREFIX))),
10079            "the failure must still be visible"
10080        );
10081        assert_eq!(
10082            outcome.models_served.len(),
10083            1,
10084            "the primary call's attribution stands"
10085        );
10086    }
10087
10088    /// Item 8: a delegate child cannot touch the parent's task list —
10089    /// `todo_write` outside the child's granted set is refused at execution
10090    /// (before dispatch), so the shared executor never runs it.
10091    #[tokio::test]
10092    async fn delegate_child_cannot_touch_the_parents_todo_list() {
10093        let dir = tempfile::tempdir().unwrap();
10094        let rt = runtime_for(dir.path()).await;
10095        let todos = Arc::new(tokio::sync::Mutex::new(super::super::todo::TodoList::new()));
10096        todos
10097            .lock()
10098            .await
10099            .write(&[json!({"text": "the parent's plan"})])
10100            .unwrap();
10101        let before = todos.lock().await.render();
10102
10103        let seen = Arc::new(StdMutex::new(Vec::new()));
10104        let script = CapturingScript {
10105            turns: vec![
10106                delegate_call(json!({"goal": "reorganize", "tools": ["calculate"]})),
10107                turn(
10108                    "writing todos",
10109                    json!([{ "id": "t", "name": "todo_write", "arguments": { "todos": [{"text": "hijacked"}] } }]),
10110                ),
10111                turn("could not", json!([])),
10112                turn("done", json!([])),
10113            ],
10114            cursor: AtomicUsize::new(0),
10115            seen: Arc::clone(&seen),
10116        };
10117        let mut cfg = delegate_cfg();
10118        cfg.todos = Some(Arc::clone(&todos));
10119        let mut messages = vec![sys("sys"), usr("task")];
10120        let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
10121        assert_eq!(outcome.status, "success");
10122        assert_eq!(
10123            todos.lock().await.render(),
10124            before,
10125            "the parent's list is untouched"
10126        );
10127        let child_second =
10128            serde_json::to_string(seen.lock().unwrap()[2].messages.as_ref().unwrap()).unwrap();
10129        assert!(
10130            child_second.contains("not granted to this delegate"),
10131            "{child_second}"
10132        );
10133    }
10134
10135    /// Item 9a: the LIVE state of the provenance marking — a child receipt
10136    /// from an external-labelled tool marks the parent's ToolResult External.
10137    #[tokio::test]
10138    async fn delegate_marks_the_parent_result_external_when_a_child_receipt_is() {
10139        let dir = tempfile::tempdir().unwrap();
10140        let rt = runtime_for(dir.path()).await;
10141        let mut tools = GeneralExecutor::tool_defs();
10142        // A network tool the built-in labels mark as crossing the boundary.
10143        tools.push(json!({
10144            "name": "http_request",
10145            "description": "Fetch a URL.",
10146            "parameters": {"type": "object", "properties": {"url": {"type": "string"}}}
10147        }));
10148        tools.push(delegate_tool_def(&tools));
10149        let cfg = AssistantConfig { tools, ..cfg() };
10150
10151        let seen = Arc::new(StdMutex::new(Vec::new()));
10152        let script = CapturingScript {
10153            turns: vec![
10154                delegate_call(json!({"goal": "fetch the page", "tools": ["http_request"]})),
10155                turn(
10156                    "fetching",
10157                    json!([{ "id": "h", "name": "http_request", "arguments": { "url": "https://example.invalid/" } }]),
10158                ),
10159                turn("could not fetch", json!([])),
10160                turn("done", json!([])),
10161            ],
10162            cursor: AtomicUsize::new(0),
10163            seen: Arc::clone(&seen),
10164        };
10165        let mut messages = vec![sys("sys"), usr("task")];
10166        let outcome = run_assistant_loop(&script, &rt, &cfg, &mut messages, |_| {}).await;
10167        assert_eq!(outcome.status, "success");
10168        let (_, external) = delegate_result(&messages);
10169        assert!(
10170            external,
10171            "a child receipt from an external-labelled tool must mark the parent's result External"
10172        );
10173    }
10174
10175    /// Item 9b: after a compaction the previous call's reported count is
10176    /// STALE (it described the pre-compaction history) and must be dropped —
10177    /// the next decision runs on the fresh estimate, so a short compacted
10178    /// history is not immediately compacted again on the old 190k number.
10179    #[tokio::test]
10180    async fn reported_count_is_reset_after_compaction_not_reused_stale() {
10181        let dir = tempfile::tempdir().unwrap();
10182        let rt = runtime_for(dir.path()).await;
10183        let seen = Arc::new(StdMutex::new(Vec::new()));
10184        let script = WindowedScript {
10185            inner: CapturingScript {
10186                turns: vec![
10187                    turn_with_usage(
10188                        "computing",
10189                        json!([{ "id": "k", "name": "calculate", "arguments": { "expression": "1+1" } }]),
10190                        190_000,
10191                        10,
10192                    ),
10193                    // Reports NO usage: a stale 190k, if kept, would compact again.
10194                    turn(
10195                        "still computing",
10196                        json!([{ "id": "k2", "name": "calculate", "arguments": { "expression": "2+2" } }]),
10197                    ),
10198                    turn("done", json!([])),
10199                ],
10200                cursor: AtomicUsize::new(0),
10201                seen: Arc::clone(&seen),
10202            },
10203            window: 200_000,
10204        };
10205        let mut messages = modest_history();
10206        let outcome = run_assistant_loop(&script, &rt, &cfg(), &mut messages, |_| {}).await;
10207        assert_eq!(outcome.status, "success");
10208        let reqs = seen.lock().unwrap();
10209        assert_eq!(reqs.len(), 3);
10210        let notice_of = |req: &GenerateRequest| {
10211            req.messages
10212                .as_ref()
10213                .unwrap()
10214                .iter()
10215                .find_map(parse_compaction_notice)
10216        };
10217        let after_first = notice_of(&reqs[1]).expect("turn 2 compacts on the 190k report");
10218        let after_second = notice_of(&reqs[2]).expect("the notice persists");
10219        assert_eq!(
10220            after_first, after_second,
10221            "no second compaction: the stale 190k report must not survive the first one"
10222        );
10223    }
10224}