Skip to main content

agent_engine/runtime/
mod.rs

1use crate::{Result, RuntimeError, ToolRegistry};
2use futures::stream::Stream;
3use reqwest::Client;
4use serde_json::{json, Value};
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::time::Duration;
10use tokio::sync::{mpsc, RwLock};
11use tokio_util::sync::CancellationToken;
12
13mod anthropic_quota;
14mod api;
15mod api_sync;
16pub mod attachments;
17mod auth;
18#[cfg(test)]
19mod body_golden;
20pub mod budget;
21mod cache_diagnosis;
22pub(crate) mod cloud_invoke;
23pub mod compaction;
24pub mod context;
25pub mod continuation;
26pub mod google_gemini;
27pub mod google_vertex;
28pub(crate) mod helpers;
29mod http_phases;
30mod image_limits;
31mod image_retention;
32pub mod keepwarm;
33pub mod tool_keepwarm;
34/// The cache-simulating provider of the tool-round keep-warm tests
35/// (`runtime::stream`, `tools::subagent`; host paths:
36/// `tests/worker_tool_keepwarm.rs`).
37#[cfg(test)]
38#[path = "../../tests/support/cache_sim.rs"]
39pub(crate) mod cache_sim;
40pub mod message_time;
41pub mod openai;
42pub mod relay;
43mod request;
44mod request_encoding;
45mod sse;
46mod sse_types;
47mod stream;
48mod chat_tools;
49pub mod subagent;
50pub mod subagent_watch;
51pub mod telemetry;
52mod read_dedupe;
53pub mod tool_spill;
54pub mod trace;
55pub mod worker_context;
56pub(crate) mod transport;
57mod types;
58
59use api::ApiMethods;
60use auth::AuthMethods;
61use helpers::HelperMethods;
62use stream::StreamMethods;
63use types::AuthState;
64pub use types::{AgentEvent, CancelCause, LlmEvent, SessionEvent, StreamEvent, TurnCompletion};
65pub use stream::activation_policy;
66
67/// Result of resolving before_tool_call extension policy.
68pub enum BeforeToolCallDecision {
69    Continue { input: Value },
70    Block { reason: String },
71}
72
73/// Emit a `before_tool_call` event and include the runtime tool name when it
74/// differs from the API-safe name. `session_id` is the owning conversation
75/// (`None` for workers → JSON `null`, unchanged). `cwd` is the folder the
76/// call runs in (`ToolCapabilities.cwd`; `None` = the process folder).
77pub async fn emit_before_tool_call(
78    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
79    tool_name: &str,
80    runtime_tool_name: Option<&str>,
81    input: Value,
82    session_id: Option<&str>,
83    cwd: Option<&Path>,
84) -> crate::extensions::hooks::events::HookResult {
85    let mut event = crate::extensions::hooks::events::HookEvent::before_tool_call(tool_name, input)
86        .with_session(session_id)
87        .with_cwd(cwd);
88    if let Some(runtime_tool_name) = runtime_tool_name {
89        event.tool_runtime_name = Some(runtime_tool_name.to_string());
90    }
91    hook_bus.emit(&event).await
92}
93
94/// Answer the Confirm dialog sends for its "Allow all from <ext>" button.
95/// `resolve_before_tool_call_result` treats it as an allow AND latches the
96/// confirm's source extension(s) in the session's
97/// [`Runtime::session_allow_all`] set, so later Confirm results from those
98/// extensions (only) are auto-approved without a prompt.
99pub use crate::tools::CONFIRM_ANSWER_ALLOW_ALL;
100
101/// Longest extension `message` shown in a Confirm prompt (chars / lines).
102/// The host-built header and the dialog's buttons and scope line must stay
103/// visible, so a long or hostile message is cut with a marker.
104const MAX_CONFIRM_MESSAGE_CHARS: usize = 1200;
105const MAX_CONFIRM_MESSAGE_LINES: usize = 12;
106
107/// Sanitize, redact and cap an extension's confirm message for display:
108/// control / bidi / zero-width characters are stripped first (prA I-R4-3:
109/// they could move the cursor or disguise text in plain-text clients),
110/// then credentials are redacted (prA I-R4-1: confirmers echo the
111/// rewritten command), then the length is capped.
112fn cap_confirm_message(message: &str) -> String {
113    let message = redact_secret_text(&crate::session::sanitize_prompt_text(message));
114    let lines: Vec<&str> = message.lines().collect();
115    let over_lines = lines.len() > MAX_CONFIRM_MESSAGE_LINES;
116    let over_chars = message.chars().count() > MAX_CONFIRM_MESSAGE_CHARS;
117    if !over_lines && !over_chars {
118        return message;
119    }
120    let kept = lines[..lines.len().min(MAX_CONFIRM_MESSAGE_LINES)].join("\n");
121    let kept: String = kept.chars().take(MAX_CONFIRM_MESSAGE_CHARS).collect();
122    format!("{kept}\n…[message truncated by host]")
123}
124
125/// Lines / chars of the final-input preview shown when the input was
126/// rewritten by an earlier extension.
127const MAX_REWRITE_PREVIEW_LINES: usize = 6;
128const MAX_REWRITE_PREVIEW_CHARS: usize = 400;
129
130/// Scrub credential shapes out of free text (a shell command, a header
131/// line, an extension's confirm message) for the Confirm prompt only. Uses
132/// the AGGRESSIVE `trace::export::redact_command_text` (the shared
133/// high-precision rules plus recall-first ones). Stored text the model or
134/// user reads back (context archive, memory, traces) keeps the precise
135/// shared scrub (prA I-R5-1). The prompt is broadcast to every attached
136/// client (observers included) and replayed on attach, so it must never
137/// show a secret.
138fn redact_secret_text(text: &str) -> String {
139    crate::runtime::trace::export::redact_command_text(text)
140}
141
142/// `redact_value` (secret-named keys, secret-shaped strings) plus
143/// [`redact_secret_text`] on every remaining string, recursively.
144fn redact_preview_value(value: &mut Value) {
145    crate::runtime::trace::export::redact_value(value);
146    match value {
147        Value::Object(map) => map.values_mut().for_each(redact_preview_value),
148        Value::Array(items) => items.iter_mut().for_each(redact_preview_value),
149        Value::String(s) => *s = redact_secret_text(s),
150        _ => {}
151    }
152}
153
154/// Host-rendered, capped, REDACTED preview of the input that will actually
155/// run: a shell-style `command` string as-is, anything else as pretty JSON.
156fn final_input_preview(input: &Value) -> String {
157    let mut input = input.clone();
158    redact_preview_value(&mut input);
159    let full = match input.get("command").and_then(Value::as_str) {
160        Some(cmd) => cmd.to_string(),
161        None => serde_json::to_string_pretty(&input).unwrap_or_else(|_| input.to_string()),
162    };
163    let lines: Vec<&str> = full.lines().collect();
164    let mut out: String = lines[..lines.len().min(MAX_REWRITE_PREVIEW_LINES)]
165        .iter()
166        .map(|l| format!("  {l}"))
167        .collect::<Vec<_>>()
168        .join("\n");
169    let mut cut = lines.len() > MAX_REWRITE_PREVIEW_LINES;
170    if out.chars().count() > MAX_REWRITE_PREVIEW_CHARS {
171        out = out.chars().take(MAX_REWRITE_PREVIEW_CHARS).collect();
172        cut = true;
173    }
174    if cut {
175        out.push_str("\n  …[preview truncated]");
176    }
177    out
178}
179
180/// Prompt body for an extension tool-call Confirm: the host-known source and
181/// tool lead, so an extension's own `message` cannot hide who is asking.
182/// When earlier extensions rewrote the input (prA N3) the header also says
183/// so, by whom, and previews the FINAL input — the one approval runs —
184/// before the extension's own message (which is cut first on small screens).
185fn confirm_prompt_body(
186    message: &str,
187    sources: &[String],
188    tool: Option<&str>,
189    rewrite: Option<(&[String], &Value)>,
190) -> String {
191    let source = if sources.is_empty() {
192        "(unknown)".to_string()
193    } else {
194        sources.join(", ")
195    };
196    let rewritten = match rewrite {
197        Some((by, input)) => format!(
198            "Input rewritten by: {}\nInput that will run:\n{}\n",
199            if by.is_empty() { "(unknown)".to_string() } else { by.join(", ") },
200            final_input_preview(input)
201        ),
202        None => String::new(),
203    };
204    // The extension's own text is labelled, so it can't pass itself off as
205    // the host-built header (e.g. a fake "Input that will run:" block).
206    // Ids and tool names are host-chosen, but sanitize the whole body too.
207    crate::session::sanitize_prompt_text(&format!(
208        "Extension: {source}\nTool: {}\n{rewritten}\nMessage from {source}:\n{}\n\nAllow this tool call?",
209        tool.unwrap_or("(unknown)"),
210        cap_confirm_message(message)
211    ))
212}
213
214/// What an approved Confirm runs: the input as modified earlier in the hook
215/// chain, if any (`Modify`), else the original (`Continue`).
216fn approved(
217    modified_input: Option<Value>,
218) -> crate::extensions::hooks::events::HookResult {
219    match modified_input {
220        Some(input) => crate::extensions::hooks::events::HookResult::Modify { input },
221        None => crate::extensions::hooks::events::HookResult::Continue,
222    }
223}
224
225/// Resolve a before_tool_call result that may request user confirmation.
226///
227/// When `auto_approve_confirms` is true, or every source extension of the
228/// Confirm (same load generation) has been latched by an earlier "Allow
229/// all" answer, `Confirm` is short-circuited to approval. Headless /
230/// non-interactive callers with neither fail closed. "Allow all" is only
231/// offered (a `ConfirmScope` is attached) when this call site has a latch
232/// to arm; otherwise the dialog offers Allow / Deny only.
233pub async fn resolve_before_tool_call_result(
234    hook_result: crate::extensions::hooks::events::HookResult,
235    secret_prompt: Option<&crate::tools::SecretPromptHandle>,
236    auto_approve_confirms: bool,
237    session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
238) -> crate::extensions::hooks::events::HookResult {
239    match hook_result {
240        crate::extensions::hooks::events::HookResult::Confirm {
241            message,
242            sources,
243            generations,
244            tool,
245            modified_input,
246            modified_by,
247        } => {
248            let keys = crate::tools::latch_keys(&sources, &generations);
249            if auto_approve_confirms {
250                tracing::info!(message = %message, "confirm auto-approved (auto_approve_confirms=true)");
251                return approved(modified_input);
252            }
253            if session_allow_all.is_some_and(|latch| latch.allows_all(&keys)) {
254                tracing::info!(
255                    message = %message,
256                    extensions = ?sources,
257                    "confirm auto-approved (extension allow-all latched)"
258                );
259                return approved(modified_input);
260            }
261
262            let Some(prompt) = secret_prompt else {
263                return crate::extensions::hooks::events::HookResult::Block {
264                    reason: format!(
265                        "Tool call requires confirmation but no interactive prompt is available: {}",
266                        redact_secret_text(&crate::session::sanitize_prompt_text(&message))
267                    ),
268                };
269            };
270
271            // No latch on this path → no scope → no Allow-all button, so the
272            // dialog and the "armed" notice never promise what can't happen.
273            let scope = (session_allow_all.is_some() && !sources.is_empty()).then(|| {
274                crate::session::ConfirmScope {
275                    extensions: sources.clone(),
276                    tool: tool.clone(),
277                    rewritten_by: if modified_input.is_some() {
278                        modified_by.clone()
279                    } else {
280                        Vec::new()
281                    },
282                }
283            });
284            let response = prompt
285                .prompt_scoped(
286                    crate::session::CONFIRM_PROMPT_TITLE.to_string(),
287                    confirm_prompt_body(
288                        &message,
289                        &sources,
290                        tool.as_deref(),
291                        modified_input.as_ref().map(|i| (modified_by.as_slice(), i)),
292                    ),
293                    scope,
294                )
295                .await;
296
297            match response.as_deref().map(str::trim) {
298                Some(answer)
299                    if answer.eq_ignore_ascii_case("yes") || answer.eq_ignore_ascii_case("y") =>
300                {
301                    approved(modified_input)
302                }
303                Some(answer) if answer.eq_ignore_ascii_case(CONFIRM_ANSWER_ALLOW_ALL) => {
304                    match session_allow_all {
305                        Some(latch) if latch.allow(&keys) => tracing::warn!(
306                            extensions = ?sources,
307                            "user chose Allow all: confirms from these extensions are auto-approved for the rest of this conversation"
308                        ),
309                        Some(_) => tracing::warn!(
310                            "user chose Allow all on an unattributed or revoked confirm; approving this call only"
311                        ),
312                        None => tracing::warn!(
313                            "user chose Allow all but this path has no session latch; approving this call only"
314                        ),
315                    }
316                    approved(modified_input)
317                }
318                _ => crate::extensions::hooks::events::HookResult::Block {
319                    reason: format!(
320                        "Tool call confirmation denied: {}",
321                        redact_secret_text(&crate::session::sanitize_prompt_text(&message))
322                    ),
323                },
324            }
325        }
326        other => other,
327    }
328}
329
330/// Resolve before_tool_call policy into executable input or a block reason.
331pub async fn resolve_before_tool_call_decision(
332    original_input: Value,
333    hook_result: crate::extensions::hooks::events::HookResult,
334    secret_prompt: Option<&crate::tools::SecretPromptHandle>,
335    auto_approve_confirms: bool,
336    session_allow_all: Option<&crate::tools::SessionConfirmLatch>,
337) -> BeforeToolCallDecision {
338    match resolve_before_tool_call_result(
339        hook_result,
340        secret_prompt,
341        auto_approve_confirms,
342        session_allow_all,
343    )
344    .await
345    {
346        crate::extensions::hooks::events::HookResult::Block { reason } => {
347            BeforeToolCallDecision::Block { reason }
348        }
349        crate::extensions::hooks::events::HookResult::Modify { input } => {
350            BeforeToolCallDecision::Continue { input }
351        }
352        _ => BeforeToolCallDecision::Continue {
353            input: original_input,
354        },
355    }
356}
357
358/// Maximum size (bytes) of a `Replace` output an extension may substitute.
359/// A transform returning more than this is clamped (with a warning) to bound
360/// memory between deserialization and the downstream tool-output truncation.
361/// Generous on purpose — legitimate compression/redaction shrinks output.
362const MAX_REPLACE_OUTPUT: usize = 1024 * 1024; // 1 MiB
363
364/// Emit an `after_tool_call` event and return the tool output to record in
365/// history — either the original `output`, or a `Replace { output }`
366/// substituted by an extension transform hook (compression, redaction,
367/// summarization). The runtime tool name is included when it differs from the
368/// API-safe name.
369///
370/// Fail-safe: any non-`Replace` result — `Continue`, a result rejected by the
371/// permission matrix, a crashed/timed-out handler, or any future variant —
372/// returns the original `output` unchanged. A misbehaving extension can never
373/// drop or corrupt a tool's output.
374///
375/// Note: the event delivered to the extension carries `tool_output` as a
376/// size-limited preview for large outputs — a ~256 KB prefix plus a
377/// `…[truncated, N total bytes]` marker (see [`HookEvent::after_tool_call`]),
378/// matching `max_tool_buffer`. The final returned string is then truncated to
379/// `max_tool_output` (the context budget) — compress-then-truncate ordering,
380/// so a transform extension sees the full buffered output and decides what to
381/// keep before the hard cap is applied.
382#[allow(clippy::too_many_arguments)] // the hook event's fields
383pub async fn emit_after_tool_call(
384    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
385    tool_name: &str,
386    runtime_tool_name: Option<&str>,
387    input: Value,
388    output: String,
389    max_tool_output: usize,
390    session_id: Option<&str>,
391    cwd: Option<&Path>,
392) -> String {
393    emit_after_tool_call_outcome(
394        hook_bus,
395        tool_name,
396        runtime_tool_name,
397        input,
398        output,
399        max_tool_output,
400        session_id,
401        cwd,
402        false,
403    )
404    .await
405    .output
406}
407
408/// Distinguish an authoritative hook replacement from ordinary budget truncation.
409/// Byte equality with the summary cannot establish whether the delta lane is stale.
410pub(super) struct AfterToolCallOutcome {
411    /// Post-hook output cut to `max_tool_output` (the legacy history text).
412    pub output: String,
413    pub replaced: bool,
414    /// The post-hook output BEFORE that cut, kept only when asked for
415    /// (`keep_uncut`, i.e. tool output virtualisation is on) and only when
416    /// the cut removed bytes: a spill must hold the whole result.
417    pub uncut: Option<String>,
418}
419
420impl AfterToolCallOutcome {
421    /// The whole post-hook output: what a virtualised result spills.
422    pub fn full_text(&self) -> &str {
423        self.uncut.as_deref().unwrap_or(&self.output)
424    }
425}
426
427#[allow(clippy::too_many_arguments)] // the hook event's fields plus the history policy
428pub(super) async fn emit_after_tool_call_outcome(
429    hook_bus: &Arc<crate::extensions::hooks::HookBus>,
430    tool_name: &str,
431    runtime_tool_name: Option<&str>,
432    input: Value,
433    output: String,
434    max_tool_output: usize,
435    session_id: Option<&str>,
436    cwd: Option<&Path>,
437    keep_uncut: bool,
438) -> AfterToolCallOutcome {
439    use crate::extensions::hooks::events::HookResult;
440    // Keep the original to return verbatim if no transform fires.
441    let original = output.clone();
442    let mut event =
443        crate::extensions::hooks::events::HookEvent::after_tool_call(tool_name, input, output)
444            .with_session(session_id)
445            .with_cwd(cwd);
446    if let Some(runtime_tool_name) = runtime_tool_name {
447        event.tool_runtime_name = Some(runtime_tool_name.to_string());
448    }
449    let mut replaced = false;
450    let post_hook = match hook_bus.emit(&event).await {
451        HookResult::Replace { mut output } => {
452            replaced = true;
453            if output.len() > MAX_REPLACE_OUTPUT {
454                tracing::warn!(
455                    tool = %tool_name,
456                    len = output.len(),
457                    cap = MAX_REPLACE_OUTPUT,
458                    "Extension Replace output exceeds cap — clamping",
459                );
460                // Floor to a UTF-8 char boundary before truncating.
461                let mut boundary = MAX_REPLACE_OUTPUT;
462                while boundary > 0 && !output.is_char_boundary(boundary) {
463                    boundary -= 1;
464                }
465                output.truncate(boundary);
466            }
467            output
468        }
469        HookResult::Continue => original,
470        other => {
471            tracing::warn!(
472                tool = %tool_name,
473                ?other,
474                "Unexpected after_tool_call result — using original output",
475            );
476            original
477        }
478    };
479    // Compress-then-truncate: apply the context-budget cap AFTER the hook.
480    // Mirrors `HelperMethods::truncate_tool_result` byte-for-byte so the
481    // no-extension path is behavior-identical to the legacy ordering.
482    let output =
483        crate::runtime::helpers::HelperMethods::truncate_tool_result(&post_hook, max_tool_output);
484    let uncut = (keep_uncut && post_hook.len() > max_tool_output).then_some(post_hook);
485    AfterToolCallOutcome {
486        output,
487        replaced,
488        uncut,
489    }
490}
491
492/// A reasoning-level substitution performed during a model change because the
493/// newly selected model does not support the previously active level.
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub struct ReasoningClamp {
496    pub from: agent_core::reasoning::ReasoningLevel,
497    pub to: agent_core::reasoning::ReasoningLevel,
498}
499
500/// The core runtime — manages API communication, tool execution, authentication,
501/// and streaming for all SynapsCLI binaries (chat, chatui, server, rpc, daemon).
502#[derive(Clone)]
503struct PromptReloadSource {
504    manifest: PathBuf,
505    context: agent_core::prompt::SelectionContext,
506    user_module: Option<agent_core::prompt::PromptModule>,
507    delegation_policy_digest: Option<String>,
508}
509
510pub struct Runtime {
511    client: Client,
512    anthropic_stream_client: Arc<std::sync::OnceLock<Client>>,
513    auth: Arc<RwLock<AuthState>>,
514    model: String,
515    tools: Arc<RwLock<ToolRegistry>>,
516    system_prompt: Option<String>,
517    /// Compiled, content-safe effective prompt metadata retained for inspection/reload.
518    effective_prompt: Option<agent_core::prompt::PromptStack>,
519    prompt_generation: u64,
520    /// Inputs retained from boot so callable reloads compile the same manifest selection.
521    prompt_reload_source: Option<PromptReloadSource>,
522    thinking_budget: u32,
523    /// Named reasoning level — canonical for Codex and future providers.
524    /// When set, overrides what `thinking_level()` returns. For legacy
525    /// Anthropic models this is derived from `thinking_budget` at read time.
526    named_level: Option<agent_core::reasoning::ReasoningLevel>,
527    /// True when the user has explicitly chosen a reasoning level (via command
528    /// or config). False for derived/default values applied by set_model or
529    /// session restore. Controls whether set_model overwrites the level with
530    /// the new model's default.
531    explicit_reasoning: bool,
532    /// Foreground by default; central subagent construction marks Worker so
533    /// logical Ultra can never recursively activate proactive orchestration.
534    codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
535    /// User override for context window size (tokens). When set, takes
536    /// precedence over the model's auto-detected window from
537    /// `models::context_window_for_model`. Lets users cap context at e.g.
538    /// 200k even on models that natively support 1M.
539    context_window_override: Option<u64>,
540    /// Opt-in context windows within a stable logical session (#112).
541    /// DARK by default: `ContextManagementMode::Off`.
542    pub(crate) continuation: continuation::SharedContinuation,
543    /// Subagent workers only: automatic context rollover into the worker's
544    /// archive directory (see `worker_context`). Inert by default and on
545    /// every foreground runtime.
546    worker_context: worker_context::WorkerContext,
547    /// Model used for compaction. Falls back to claude-sonnet-4-6 if not set.
548    compaction_model: Option<String>,
549    /// Where compaction summarization runs (spec §9.4).
550    compaction_mode: agent_core::compaction::CompactionMode,
551    /// Content classes excluded from remote compaction disclosure.
552    compaction_exclusions: Vec<agent_core::compaction::ContentClass>,
553    /// Transport-construction seam (spec §9.4 / CP-12 M3): incremented at
554    /// the SINGLE remote-summarization entry point before any preflight,
555    /// auth, or HTTP request construction. Shared across clones so the
556    /// local-only zero-network proof observes every path.
557    remote_summarization_attempts: Arc<std::sync::atomic::AtomicU64>,
558    /// Shared registry for reactive subagent handles.
559    subagent_registry: Arc<Mutex<crate::runtime::subagent::SubagentRegistry>>,
560    /// Session-scoped orchestration enforcement installed during boot.
561    orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
562    /// Shared event queue — for Event Bus tooling.
563    event_queue: Arc<crate::events::EventQueue>,
564    // New configurable fields
565    max_tool_output: usize,
566    /// Tool output virtualisation thresholds (`tool_output_spill_*`). OFF
567    /// on construction — `Runtime::new()` is also the embedding/test path
568    /// and must not write under the base dir unless configured; every
569    /// production boot applies the config (default on), workers through
570    /// `apply_subagent_runtime_policy`.
571    tool_spill_policy: tool_spill::ToolSpillPolicy,
572    /// `<base>/spill`, bound once at construction (like `capture_dir`).
573    tool_spill_root: PathBuf,
574    /// Session-cumulative virtualisation counters; shared by clones.
575    tool_spill_stats: Arc<tool_spill::ToolSpillStats>,
576    /// Replace re-reads of unchanged files with a stub and note write/edit
577    /// results. Off until a config is applied (production default on).
578    read_dedupe: bool,
579    /// `thinking_display`: the foreground session's `thinking.display`
580    /// ([`Self::wire_thinking_display`]). Workers always send summarized.
581    thinking_display: crate::config::ThinkingDisplay,
582    /// Keep the cache warm while a tool round runs (`cache_keepwarm_tools`,
583    /// `runtime::tool_keepwarm`). Off until a config is applied
584    /// (production default on).
585    tool_keepwarm: bool,
586    bash_timeout: u64,
587    bash_max_timeout: u64,
588    subagent_timeout: u64,
589    api_retries: u32,
590    refusal_retries: u32,
591    /// Telemetry level for structured per-request API logging (opt-in).
592    telemetry_level: crate::runtime::telemetry::TelemetryLevel,
593    /// Rotated generations kept per log (`telemetry_log_generations`), read
594    /// when the session writer starts.
595    telemetry_log_generations: u32,
596    /// Session-shared bounded observability writer (Task 11, spec §6.5).
597    /// `Some` iff `telemetry_level` is Basic/Full. Cloned runtimes
598    /// (subagents) share the same handle — one worker per session, never
599    /// per request. Dropping the last handle detaches the worker (it drains
600    /// and exits on its own), so `Drop` can never hang.
601    telemetry_writer: Option<crate::runtime::telemetry::TelemetryWriter>,
602    /// Trace context handed to every `ApiOptions` site. Enabled (writer
603    /// sink) iff `telemetry_writer` is `Some`; otherwise the no-op sink.
604    /// Shared across clones so all requests of the session carry the same
605    /// session-scoped context IDs.
606    trace_ctx: trace::TraceContext,
607    /// Explicit one-shot trace controls (Task 12): `/trace next` arms
608    /// exactly the next outgoing provider request, even when telemetry is
609    /// Off, then auto-disarms. Shared across clones.
610    trace_controls: std::sync::Arc<trace::TraceControls>,
611    /// The chat's memory binding: the captured project of its folder (#112).
612    /// Until it is bound, the project of the process folder, as before.
613    memory_backend: crate::memory_backend::MemoryBinding,
614    /// Whether the binding has been made ([`Self::bind_memory`]) or given
615    /// ([`Self::inherit_memory_binding`]). It is made once and never
616    /// replaced.
617    memory_bound: bool,
618    /// Writer handle backing the most recent armed one-shot ephemeral
619    /// trace context (telemetry Off + `/trace next`). Retained here — not
620    /// only inside the request's cloned context — so the session exit
621    /// epilogue (`shutdown_observability*`) can drain the armed record
622    /// even if the process exits right after the request. Replaced on
623    /// re-arm; never touched on the request path beyond one mutex store.
624    /// Shared across clones.
625    one_shot_trace_writer:
626        std::sync::Arc<std::sync::Mutex<Option<crate::runtime::telemetry::TelemetryWriter>>>,
627    /// Content-capture root, bound ONCE at construction (fix1 I2b): every
628    /// sweep — startup, status, sync and async shutdown epilogues — and the
629    /// one-shot content capture use THIS path, never a late ambient
630    /// `SYNAPS_BASE_DIR` read, so post-construction env churn (parallel
631    /// tests, profile switches) can never redirect capture I/O.
632    capture_dir: std::path::PathBuf,
633    /// Opt into the cache-diagnosis beta (`cache-diagnosis-2026-04-07`).
634    cache_diagnostics: bool,
635    /// Configured prompt-cache TTL (`auto` default | 5m | 1h | hybrid).
636    /// Threaded into every request via `ApiOptions`; `auto` is resolved per
637    /// request from the Anthropic credential kind (1h OAuth, 5m API key).
638    cache_ttl: crate::core::config::CacheTtlSetting,
639    /// gzip policy for Anthropic request bodies (`request_compression`,
640    /// `request_compression_level`). Threaded into every request via
641    /// `ApiOptions`; its refusal latch is process-wide.
642    request_compression: request_encoding::RequestCompression,
643    /// One-time-per-session latch for the silent 1h-downgrade notice
644    /// (spec §3.4.1). Shared into `ApiOptions` for every request.
645    ttl_downgrade_notified: std::sync::Arc<std::sync::atomic::AtomicBool>,
646    /// Session-scoped "1h honored at least once" latch (spec §3.4.1) —
647    /// suppresses the downgrade notice on healthy Hybrid turns where the 1h
648    /// prefix is already cached. Shared into `ApiOptions` for every request.
649    saw_1h_honored: std::sync::Arc<std::sync::atomic::AtomicBool>,
650    /// When this runtime's foreground request chain last had an Anthropic
651    /// request accepted (task #444 keep-warm scheduling). Fresh per clone,
652    /// shared by `share_ttl_latches`, like the TTL latches above.
653    cache_anchor: std::sync::Arc<keepwarm::CacheAnchorCell>,
654    /// Last foreground Anthropic message id (`msg_...`) — threaded into the
655    /// next request's `diagnostics.previous_message_id` when diagnostics is
656    /// on. Fresh per clone, shared by `share_ttl_latches`.
657    last_msg_id: Arc<Mutex<Option<String>>>,
658    /// Set once the API rejected cache diagnostics: off for the session.
659    /// Shared across `Clone` (subagents never re-probe a rejecting API).
660    cache_diag_rejected: Arc<std::sync::atomic::AtomicBool>,
661    session_manager: std::sync::Arc<crate::tools::shell::SessionManager>,
662    /// Extension hook bus for dispatching events to extensions.
663    hook_bus: Arc<crate::extensions::hooks::HookBus>,
664    // Held to keep the reaper task alive for the Runtime's lifetime; never read directly.
665    #[allow(dead_code)]
666    reaper_handle: Option<tokio::task::JoinHandle<()>>,
667    #[allow(dead_code)]
668    reaper_cancel: Option<tokio_util::sync::CancellationToken>,
669    /// How provider credentials are resolved: `Local` (read/refresh auth.json —
670    /// the default) or `Remote` (fetch short-lived access tokens from a broker).
671    /// Set from config in `apply_config`. See task #157.
672    credential_source: crate::auth::CredentialSource,
673    /// In-memory cache of broker-fetched access tokens (Remote source only).
674    /// Cheap to clone (Arc inside). Never persisted to disk.
675    token_cache: crate::auth::TokenCache,
676    /// Exact operator-trusted worker identities loaded from `favorite_models`.
677    /// These seed the session policy and are replayed when a manifestless
678    /// foreground model change replaces that policy snapshot.
679    trusted_worker_models: Vec<agent_core::prompt::QualifiedModelId>,
680    /// Subagent fan-out limits (`subagent.*`) used whenever this runtime
681    /// installs a manifestless delegation policy (boot, new session, model
682    /// change). Changing them affects the next install, not running workers.
683    subagent_limits: crate::config::SubagentLimits,
684    /// True when this stream should expose only its session projection rather
685    /// than the legacy full tool schema. Opt-in and false by default so the
686    /// flag-off request bytes stay unchanged (Task 18).
687    progressive_tool_disclosure: bool,
688    /// `tools.always_loaded`: tool ids joined to the progressive session core
689    /// (`SessionToolSet::progressive_core_with`). Inert without disclosure.
690    tools_always_loaded: Arc<[crate::tools::catalog::ToolId]>,
691    /// `tools.deferred_loading`: Anthropic `defer_loading` + `tool_reference`
692    /// for progressive disclosure, so activations keep the cached prefix.
693    deferred_tool_loading: bool,
694    /// `tools.activation_confirm` host policy for model-initiated
695    /// `activate_tools` (auto | prompt | deny). Default `auto`.
696    activation_confirm: agent_core::config::ActivationConfirm,
697    /// Current worker handle for bounded delegation-tree accounting. `None`
698    /// for foreground roots.
699    delegation_parent: Option<String>,
700    /// Session-scoped, per-extension "Allow all" latch for extension
701    /// `Confirm` gates (`before_tool_call`). An extension id is added when
702    /// the user picks "Allow all from <ext>" in the Confirm dialog; shared by
703    /// clones and delegated workers in the same session. Never persisted.
704    session_allow_all: std::sync::Arc<crate::tools::SessionConfirmLatch>,
705    /// Shared exact MCP lease manager (Task 19). Installed at engine boot
706    /// when MCP exact mode is active; streams mint per-session capabilities
707    /// and RAII guards from it.
708    mcp_runtime: Option<std::sync::Arc<crate::mcp::McpRuntimeManager>>,
709    /// Durable shared session-scope guard (Task 19 review): terminates this
710    /// runtime session's MCP leases only when the LAST owner (runtime clone
711    /// or in-flight stream) drops — never per provider turn.
712    mcp_session_scope: Option<std::sync::Arc<crate::mcp::McpSessionEndGuard>>,
713    /// Shared exact EXTENSION lease manager (Task 20). Installed at engine
714    /// boot under progressive disclosure; streams mint per-session
715    /// capabilities and hold the durable scope below.
716    extension_runtime: Option<std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>>,
717    /// Durable shared session-scope guard for extension leases (Task 20):
718    /// terminates this runtime session's extension leases only when the
719    /// LAST owner (runtime clone or in-flight stream) drops.
720    extension_session_scope:
721        Option<std::sync::Arc<crate::extensions::lease::ExtensionSessionEndGuard>>,
722    /// Per-turn budget (Task 23, spec §8.1). Resolved from role + typed
723    /// config at boot; every stream turn is metered against it.
724    turn_budget: crate::runtime::budget::TurnBudget,
725    /// Set while an event-started turn's stream starts (like the event
726    /// budget in `turn_budget`): the owner's steer moves that stream onto
727    /// the session budget. `None` for every other stream.
728    owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
729    /// Source of the per-message `_synaps_at` times (see `message_time`).
730    message_clock: crate::runtime::message_time::MessageClock,
731    /// Private runtime-scoped tool-session identity (Task 16). Scopes the
732    /// per-stream `SessionToolSet` the execution gate authorizes against.
733    /// Minted fresh per constructed `Runtime` — two independently
734    /// constructed runtimes can never share session grants — and shared by
735    /// `Clone` because clones share the same live tool registry (the
736    /// existing shared-session behavior). Never persisted; unrelated to
737    /// saved session IDs.
738    host_tool_session: crate::tools::activation::SessionId,
739    /// Frozen system-prompt catalog of activatable tools (progressive
740    /// disclosure): rendered once per session on first use, then reused
741    /// byte-for-byte so activations and late registrations never change the
742    /// cached prompt prefix. Outer `None` = not rendered yet.
743    tool_catalog_prompt: Arc<std::sync::Mutex<Option<Option<String>>>>,
744    /// The `SessionToolSet` retained for `host_tool_session` across stream
745    /// turns (exact activations are session-scoped). Minted empty with the
746    /// runtime and shared by `Clone`, exactly like `host_tool_session`.
747    retained_tool_set: crate::tools::activation::RetainedSessionToolSet,
748    /// Activations carried over from this conversation's previous runtime
749    /// (daemon reload, unpark), restored at the first progressive turn
750    /// start. Shared by `Clone` like `retained_tool_set`.
751    pending_tool_restore: crate::tools::activation::PendingToolRestore,
752    /// Conversation/session identity this runtime serves. Keys the
753    /// `on_session_start` hook injection (Phase 2 keys everything).
754    /// `None` = unkeyed (workers, tests) — reads no injection.
755    session_id: Option<String>,
756    /// Per-session working directory (Phase 2 daemon mode). `None` = process
757    /// cwd — every in-process host leaves it `None`, so `ToolCapabilities.cwd`
758    /// stays `None` exactly as before. The daemon sets it per session.
759    cwd: Option<PathBuf>,
760    /// Per-session environment snapshot (session-identity T1). `None` =
761    /// inherit process env (in-process hosts). Daemon sessions carry the
762    /// creator client's env; tools apply `env_clear().envs()` when `Some`.
763    env: Option<crate::session::types::ToolEnv>,
764    /// Names of env vars stripped as secrets (T5).
765    env_stripped: Vec<String>,
766    /// Shared per-session "already warned" set for env-stripped notices (T5).
767    /// Cloned into every `ToolCapabilities` so the dedup survives across
768    /// tool calls within the same session.
769    env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
770}
771
772/// Mint a fresh runtime-scoped tool-session identity. Process id + UUIDv4
773/// keeps it unique across runtimes and restarts; the parse cannot fail on
774/// this generated shape.
775fn fresh_host_tool_session() -> crate::tools::activation::SessionId {
776    crate::tools::activation::SessionId::parse(&format!(
777        "runtime-{}-{}",
778        std::process::id(),
779        uuid::Uuid::new_v4()
780    ))
781    .expect("generated runtime session id is always valid")
782}
783
784/// Idle timeout for the runtime HTTP client: how long a request may go
785/// without receiving *any* bytes (headers or body chunks) before it is
786/// killed. Resets on every received chunk, so healthy long-running streams
787/// are never interrupted.
788const HTTP_READ_TIMEOUT: Duration = Duration::from_secs(90);
789
790/// Build the runtime's shared HTTP client.
791///
792/// Timeout design (incident: session 20260714-025948-3dab):
793/// * `connect_timeout(10s)` — connection-establishment ceiling.
794/// * `read_timeout` — **idle** detector; resets after each successful read
795///   (reqwest `ReadTimeoutBody`). A hung request surfaces in seconds
796///   instead of minutes, while an actively-streaming turn can run
797///   indefinitely.
798/// * Deliberately **no** total `.timeout(…)`: that was a wall-clock
799///   deadline that kept ticking while bytes flowed, killing any healthy
800///   stream longer than 300s and taking 300s to notice a dead connection.
801///   Turn-level lifecycle is owned by cancellation tokens, not the client.
802fn build_http_client(read_timeout: Duration) -> reqwest::Result<Client> {
803    Client::builder()
804        .tls_built_in_webpki_certs(true)
805        .tls_built_in_native_certs(true)
806        .connect_timeout(Duration::from_secs(10))
807        .read_timeout(read_timeout)
808        .build()
809}
810
811/// The process-global HTTP client for `EngineHost` (same builder as
812/// `Runtime::new`).
813/// The host's HTTP client builder (also used by the attach-TUI client, A4).
814pub fn build_host_http_client() -> Result<Client> {
815    build_http_client(HTTP_READ_TIMEOUT)
816        .map_err(|e| RuntimeError::Config(format!("Failed to build HTTP client: {}", e)))
817}
818
819/// Internal: every field of `Runtime` in one place so `new()`,
820/// `new_headless()`, `foreground_runtime()` and `worker_runtime()` cannot
821/// drift. `Runtime::new()` is: build fresh parts → `from_parts`.
822pub(crate) struct RuntimeParts {
823    pub host: crate::host::HostParts,
824    pub session_manager: Arc<crate::tools::shell::SessionManager>,
825    pub reaper: Option<(tokio::task::JoinHandle<()>, CancellationToken)>,
826}
827
828impl RuntimeParts {
829    /// Fresh shell session manager + idle reaper (needs a tokio runtime).
830    pub(crate) fn with_reaper(host: crate::host::HostParts) -> Self {
831        let session_manager = fresh_session_manager();
832        let cancel = CancellationToken::new();
833        let handle =
834            crate::tools::shell::session::start_reaper(session_manager.clone(), cancel.clone());
835        Self {
836            host,
837            session_manager,
838            reaper: Some((handle, cancel)),
839        }
840    }
841
842    /// Fresh shell session manager, no reaper (offline / headless).
843    #[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
844    pub(crate) fn without_reaper(host: crate::host::HostParts) -> Self {
845        Self {
846            host,
847            session_manager: fresh_session_manager(),
848            reaper: None,
849        }
850    }
851}
852
853fn fresh_session_manager() -> Arc<crate::tools::shell::SessionManager> {
854    let config = crate::tools::shell::ShellConfig::default();
855    crate::tools::shell::SessionManager::new(config)
856}
857
858/// Fresh, process-local host parts — exactly what `Runtime::new()` used to
859/// construct inline: new client, new registry, new hook bus, Local creds,
860/// new token cache, no lease managers.
861fn fresh_host_parts() -> Result<crate::host::HostParts> {
862    let client = build_host_http_client()?;
863    // Operational retention (Task 12): physically remove expired
864    // content-capture bundles at session startup — bounded, fail-soft,
865    // confined to the private capture dir. The root resolved here is
866    // the SAME value bound into `capture_dir` (fix1 I2b).
867    let capture_dir = trace::default_capture_dir();
868    let _ = trace::sweep_expired_captures(&capture_dir);
869    // Same for tool-output spill dirs past their age (bound here too).
870    let spill_root = sweep_spill_root();
871    Ok(crate::host::HostParts {
872        client,
873        tools: Arc::new(RwLock::new(ToolRegistry::new())),
874        hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
875        credential_source: crate::auth::CredentialSource::Local,
876        token_cache: crate::auth::TokenCache::new(),
877        mcp_runtime: None,
878        extension_runtime: None,
879        capture_dir,
880        spill_root,
881        progressive_tool_disclosure: false,
882        tools_always_loaded: Arc::from([]),
883    })
884}
885
886/// Resolve `<base>/spill` once and remove session spill dirs past
887/// [`agent_core::core::tool_spill::SPILL_MAX_AGE`] — bounded, fail-soft,
888/// confined to the spill root, like the capture sweep beside it.
889pub(crate) fn sweep_spill_root() -> PathBuf {
890    let root = agent_core::core::tool_spill::default_spill_root();
891    let _ = agent_core::core::tool_spill::sweep_stale_spill(
892        &root,
893        agent_core::core::tool_spill::SPILL_MAX_AGE,
894        std::time::SystemTime::now(),
895    );
896    root
897}
898
899/// Preserve compatibility with favorite IDs written before Anthropic used its
900/// runtime-qualified provider name. Authorization always stores the canonical
901/// exact identity; unrelated bare values remain invalid and are ignored.
902fn canonical_trusted_worker_model(model: &str) -> String {
903    let model = model.trim();
904    if let Some(id) = model.strip_prefix("claude/") {
905        format!("anthropic/{id}")
906    } else if model.starts_with("claude-") {
907        format!("anthropic/{model}")
908    } else {
909        model.to_owned()
910    }
911}
912
913/// The owning runtime stops its shell-session reaper when it goes away
914/// (clones carry `None`). Without this every dropped `Runtime` — a parked
915/// session, a finished worker — left a 30 s ticker holding the manager.
916impl Drop for Runtime {
917    fn drop(&mut self) {
918        if let Some(c) = &self.reaper_cancel {
919            c.cancel();
920        }
921    }
922}
923
924impl Runtime {
925    pub async fn new() -> Result<Self> {
926        // UNCHANGED semantics: fresh everything (tests).
927        Ok(Self::from_parts(RuntimeParts::with_reaper(
928            fresh_host_parts()?,
929        )))
930    }
931
932    /// The single struct-literal site for `Runtime`. Host-owned parts come
933    /// from `parts.host`; everything else is fresh.
934    pub(crate) fn from_parts(parts: RuntimeParts) -> Self {
935        // Runtime construction is credential-blind. Credentials are acquired
936        // lazily through the broker abstraction after configuration is applied;
937        // this layer never opens auth.json or consults a secret environment var.
938        let (auth_token, auth_type, refresh_token, token_expires) =
939            (String::new(), "oauth".to_string(), None, Some(0));
940        let RuntimeParts {
941            host,
942            session_manager,
943            reaper,
944        } = parts;
945        let (reaper_handle, reaper_cancel) = match reaper {
946            Some((h, c)) => (Some(h), Some(c)),
947            None => (None, None),
948        };
949        let mcp_runtime = host.mcp_runtime;
950        let extension_runtime = host.extension_runtime;
951
952        let mut runtime = Runtime {
953            client: host.client,
954            anthropic_stream_client: Default::default(),
955            auth: Arc::new(RwLock::new(AuthState {
956                auth_token,
957                auth_type,
958                refresh_token,
959                token_expires,
960                auth_file_mtime: None,
961                bound_credential: None,
962            })),
963            model: crate::models::default_model().to_string(),
964            tools: host.tools,
965            system_prompt: None,
966            effective_prompt: None,
967            prompt_generation: 0,
968            prompt_reload_source: None,
969            thinking_budget: 4096,
970            named_level: None,
971            explicit_reasoning: false,
972            codex_request_role: crate::runtime::openai::catalog::CodexRequestRole::Foreground,
973            context_window_override: None,
974            compaction_model: None,
975            compaction_mode: agent_core::compaction::CompactionMode::default(),
976            compaction_exclusions: Vec::new(),
977            remote_summarization_attempts: Arc::new(std::sync::atomic::AtomicU64::new(0)),
978            subagent_registry: Arc::new(Mutex::new(
979                crate::runtime::subagent::SubagentRegistry::new(),
980            )),
981            orchestration: None,
982            event_queue: Arc::new(crate::events::EventQueue::new(1000)),
983            max_tool_output: 30000,
984            tool_spill_policy: tool_spill::ToolSpillPolicy::OFF,
985            tool_spill_root: host.spill_root,
986            tool_spill_stats: Arc::default(),
987            read_dedupe: false,
988            thinking_display: crate::config::ThinkingDisplay::Summarized,
989            tool_keepwarm: false,
990            bash_timeout: 30,
991            bash_max_timeout: 300,
992            subagent_timeout: 300,
993            api_retries: 3,
994            refusal_retries: 2,
995            telemetry_level: crate::runtime::telemetry::TelemetryLevel::Off,
996            telemetry_log_generations: crate::runtime::telemetry::DEFAULT_MAX_GENERATIONS,
997            telemetry_writer: None,
998            trace_ctx: trace::TraceContext::disabled(),
999            trace_controls: std::sync::Arc::new(trace::TraceControls::new()),
1000            // Unbound until `bind_memory`, which replaces this binding with the
1001            // project of the folder given by `set_cwd`; until then, the process
1002            // folder's (as `legacy_current()` did). Continuation off (#112).
1003            memory_backend: crate::memory_backend::MemoryBinding::for_cwd(
1004                std::env::current_dir().ok(),
1005            ),
1006            memory_bound: false,
1007            continuation: Arc::new(Mutex::new(continuation::ContinuationState::default())),
1008            worker_context: Default::default(),
1009            one_shot_trace_writer: std::sync::Arc::new(std::sync::Mutex::new(None)),
1010            capture_dir: host.capture_dir,
1011            cache_diagnostics: false,
1012            cache_ttl: crate::core::config::CacheTtlSetting::default(),
1013            request_compression: Default::default(),
1014            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1015            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1016            cache_anchor: Default::default(),
1017            last_msg_id: Arc::new(Mutex::new(None)),
1018            cache_diag_rejected: Default::default(),
1019            session_manager,
1020            hook_bus: host.hook_bus,
1021            reaper_handle,
1022            reaper_cancel,
1023            credential_source: host.credential_source,
1024            token_cache: host.token_cache,
1025            trusted_worker_models: Vec::new(),
1026            subagent_limits: crate::config::SubagentLimits::default(),
1027            progressive_tool_disclosure: host.progressive_tool_disclosure,
1028            tools_always_loaded: host.tools_always_loaded,
1029            deferred_tool_loading: true,
1030            activation_confirm: agent_core::config::ActivationConfirm::default(),
1031            delegation_parent: None,
1032            session_allow_all: std::sync::Arc::new(crate::tools::SessionConfirmLatch::new()),
1033            mcp_runtime: None,
1034            mcp_session_scope: None,
1035            extension_runtime: None,
1036            extension_session_scope: None,
1037            turn_budget: crate::runtime::budget::TurnBudget::for_role(
1038                crate::runtime::budget::TurnRole::Foreground,
1039            ),
1040            owner_steer_budget: None,
1041            message_clock: Default::default(),
1042            host_tool_session: fresh_host_tool_session(),
1043            tool_catalog_prompt: Arc::new(std::sync::Mutex::new(None)),
1044            retained_tool_set: Default::default(),
1045            pending_tool_restore: Default::default(),
1046            session_id: None,
1047            cwd: None,
1048            env: None,
1049            env_stripped: Vec::new(),
1050            env_warned: Default::default(),
1051        };
1052        // Lease managers are installed through the same seams boot used, so
1053        // the per-runtime durable session-scope guards are minted exactly as
1054        // before (keyed by this runtime's fresh host tool session).
1055        if let Some(m) = mcp_runtime {
1056            runtime.install_mcp_runtime(m);
1057        }
1058        if let Some(m) = extension_runtime {
1059            runtime.install_extension_runtime(m);
1060        }
1061        runtime
1062    }
1063
1064
1065    /// Offline construction seam for headless test harnesses (P4).
1066    ///
1067    /// Identical to [`Runtime::new`] except:
1068    /// - auth is a stub token — no `auth.json` read, no keychain, no network
1069    /// - the idle shell-session reaper is not spawned — no tokio runtime
1070    ///   required at construction time
1071    ///
1072    /// Every accessor (`model()`, `thinking_level()`, tool registry, …) works
1073    /// normally; anything that would hit the Anthropic API fails at call time,
1074    /// which is the correct behavior for a harness that only drives the UI.
1075    #[cfg(any(test, feature = "testing"))]
1076    pub fn new_headless() -> Self {
1077        let client = build_http_client(HTTP_READ_TIMEOUT)
1078            .expect("reqwest client construction is infallible with built-in roots");
1079        let host = crate::host::HostParts {
1080            client,
1081            tools: Arc::new(RwLock::new(ToolRegistry::new())),
1082            hook_bus: Arc::new(crate::extensions::hooks::HookBus::new()),
1083            credential_source: crate::auth::CredentialSource::Local,
1084            token_cache: crate::auth::TokenCache::new(),
1085            mcp_runtime: None,
1086            extension_runtime: None,
1087            capture_dir: trace::default_capture_dir(),
1088            spill_root: agent_core::core::tool_spill::default_spill_root(),
1089            progressive_tool_disclosure: false,
1090            tools_always_loaded: Arc::from([]),
1091        };
1092        let mut runtime = Self::from_parts(RuntimeParts::without_reaper(host));
1093        runtime.auth = Arc::new(RwLock::new(AuthState {
1094            auth_token: "test-token".to_string(),
1095            auth_type: "api_key".to_string(),
1096            refresh_token: None,
1097            token_expires: None,
1098            auth_file_mtime: None,
1099            bound_credential: None,
1100        }));
1101        runtime
1102    }
1103
1104    pub fn set_system_prompt(&mut self, prompt: String) {
1105        self.system_prompt = Some(prompt);
1106    }
1107
1108    pub fn system_prompt(&self) -> Option<&str> {
1109        self.system_prompt.as_deref()
1110    }
1111
1112    /// The system prompt that actually goes on the wire for the next
1113    /// request: the configured base plus any builtin orchestration adapter
1114    /// (see [`agent_core::prompt::builtin_orchestration_adapters`]) selected
1115    /// by the *current* model identity, so the doctrine follows mid-session
1116    /// model switches. Composition is skipped when:
1117    /// - a typed prompt manifest is active — the manifest author owns the
1118    ///   full stack;
1119    /// - the runtime exposes no subagent tools (worker runtimes) — the
1120    ///   doctrine would be unactionable noise;
1121    /// - the model identity cannot be canonicalized — fail closed to the
1122    ///   unmodified base.
1123    pub async fn effective_system_prompt(&self) -> Option<String> {
1124        let base = self.orchestrated_system_prompt().await;
1125        // A typed prompt manifest owns the full stack: nothing is appended.
1126        if self.effective_prompt.is_some() {
1127            return base;
1128        }
1129        match self.frozen_tool_catalog().await {
1130            Some(catalog) => Some(match base {
1131                Some(base) if !base.is_empty() => format!("{base}\n\n{catalog}"),
1132                _ => catalog,
1133            }),
1134            None => base,
1135        }
1136    }
1137
1138    /// The progressive-disclosure catalog of activatable tools for the
1139    /// system prompt, frozen per session (see `tool_catalog_prompt`). `None`
1140    /// when progressive disclosure is off, model-initiated activation is
1141    /// denied, the runtime cannot activate tools (workers), or nothing
1142    /// outside the session core is activatable.
1143    pub async fn frozen_tool_catalog(&self) -> Option<String> {
1144        if !self.progressive_tool_disclosure
1145            || self.activation_confirm == agent_core::config::ActivationConfirm::Deny
1146        {
1147            return None;
1148        }
1149        if let Some(frozen) = self
1150            .tool_catalog_prompt
1151            .lock()
1152            .unwrap_or_else(std::sync::PoisonError::into_inner)
1153            .clone()
1154        {
1155            return frozen;
1156        }
1157        let rendered = {
1158            let registry = self.tools.read().await;
1159            if registry.get("activate_tools").is_none() {
1160                None
1161            } else {
1162                let core_set = chat_tools::chat_tool_set(
1163                    self.host_tool_session.clone(),
1164                    registry.catalog(),
1165                    true,
1166                    self.context_management_enabled(),
1167                    &self.tools_always_loaded,
1168                );
1169                let core: std::collections::HashSet<String> = core_set
1170                    .core_ids()
1171                    .map(|id| id.as_str().to_string())
1172                    .collect();
1173                // What this chat is not offered it cannot activate either.
1174                crate::tools::prompt_catalog::render(
1175                    &registry.catalog().without(core_set.withheld()),
1176                    &core,
1177                    crate::tools::activation::is_activatable_record,
1178                )
1179            }
1180        };
1181        let mut slot = self
1182            .tool_catalog_prompt
1183            .lock()
1184            .unwrap_or_else(std::sync::PoisonError::into_inner);
1185        // First render wins (a concurrent caller may have frozen it already).
1186        slot.get_or_insert(rendered).clone()
1187    }
1188
1189    /// The configured base prompt plus the builtin orchestration doctrine
1190    /// for the current model (what `effective_system_prompt` returned before
1191    /// the tool catalog was appended).
1192    async fn orchestrated_system_prompt(&self) -> Option<String> {
1193        if self.effective_prompt.is_some() {
1194            return self.system_prompt.clone();
1195        }
1196        if self.tools.read().await.get("subagent_start").is_none() {
1197            return self.system_prompt.clone();
1198        }
1199        let Ok(model) = crate::orchestration::canonical_foreground_identity(&self.model) else {
1200            return self.system_prompt.clone();
1201        };
1202        let workflow_mode = match self.reasoning_level() {
1203            agent_core::reasoning::ReasoningLevel::UltraCode => {
1204                Some(agent_core::prompt::WorkflowMode::UltraCode)
1205            }
1206            agent_core::reasoning::ReasoningLevel::Max => {
1207                Some(agent_core::prompt::WorkflowMode::Max)
1208            }
1209            agent_core::reasoning::ReasoningLevel::XHigh => {
1210                Some(agent_core::prompt::WorkflowMode::XHigh)
1211            }
1212            _ => None,
1213        };
1214        let Ok(context) = agent_core::prompt::SelectionContext::new(model, None)
1215            .map(|context| context.with_workflow_mode(workflow_mode))
1216        else {
1217            return self.system_prompt.clone();
1218        };
1219        agent_core::prompt::compose_orchestration_prompt(self.system_prompt.as_deref(), &context)
1220    }
1221
1222    pub fn effective_prompt(&self) -> Option<&agent_core::prompt::PromptStack> {
1223        self.effective_prompt.as_ref()
1224    }
1225
1226    pub fn prompt_generation(&self) -> u64 {
1227        self.prompt_generation
1228    }
1229
1230    pub fn retain_prompt_reload_source(
1231        &mut self,
1232        manifest: PathBuf,
1233        context: agent_core::prompt::SelectionContext,
1234        user_module: Option<agent_core::prompt::PromptModule>,
1235        delegation_policy_digest: Option<String>,
1236    ) {
1237        self.prompt_reload_source = Some(PromptReloadSource {
1238            manifest,
1239            context,
1240            user_module,
1241            delegation_policy_digest,
1242        });
1243    }
1244
1245    /// Recompile retained manifest inputs, validate, then atomically install the candidate.
1246    pub fn reload_prompt(&mut self) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1247        let source = self.prompt_reload_source.clone().ok_or_else(|| {
1248            agent_core::prompt::PromptError::Invalid("no prompt manifest is active".into())
1249        })?;
1250        let raw = std::fs::read_to_string(&source.manifest).map_err(|_| {
1251            agent_core::prompt::PromptError::Invalid("prompt manifest is unavailable".into())
1252        })?;
1253        let manifest = agent_core::prompt::PromptManifest::parse(&raw)?;
1254        let reload_catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
1255            source.context.model(),
1256            manifest.delegation_catalog_candidates(),
1257        )
1258        .map_err(|error| agent_core::prompt::PromptError::Invalid(error.into()))?;
1259        let candidate_policy_digest = manifest
1260            .delegation_policy(source.context.model().clone(), &reload_catalog)?
1261            .map(|policy| policy.digest());
1262        if candidate_policy_digest != source.delegation_policy_digest {
1263            return Err(agent_core::prompt::PromptError::Invalid(
1264                "hot reload cannot safely change delegation policy".into(),
1265            ));
1266        }
1267        let registry = manifest.registry(source.manifest.parent())?;
1268        let candidate = agent_core::prompt::compile_prompt_stack(
1269            &manifest,
1270            &registry,
1271            &source.context,
1272            source.user_module,
1273        )?;
1274        self.apply_prompt_stack(candidate)
1275    }
1276
1277    /// Atomically validate and install a compiled stack. Failed validation leaves all state intact.
1278    pub fn apply_prompt_stack(
1279        &mut self,
1280        candidate: agent_core::prompt::PromptStack,
1281    ) -> std::result::Result<u64, agent_core::prompt::PromptError> {
1282        if let Some(current) = &self.effective_prompt {
1283            current.validate_hot_reload(&candidate)?;
1284        }
1285        let composed = candidate.composed().to_owned();
1286        self.effective_prompt = Some(candidate);
1287        self.system_prompt = Some(composed);
1288        self.prompt_generation = self.prompt_generation.saturating_add(1);
1289        Ok(self.prompt_generation)
1290    }
1291
1292    pub fn prompt_inspection_json(&self) -> Option<String> {
1293        let mode = self
1294            .orchestration
1295            .as_ref()
1296            .map(|runtime| runtime.enforcement_mode())
1297            .unwrap_or(agent_core::orchestration::EnforcementMode::Off);
1298        self.effective_prompt.as_ref().and_then(|stack| {
1299            serde_json::to_string(&serde_json::json!({
1300                "generation": self.prompt_generation,
1301                "effective": stack.inspect(mode),
1302                "token_estimate": stack.composed().len().div_ceil(4),
1303            }))
1304            .ok()
1305        })
1306    }
1307
1308    /// Install the shared exact MCP lease manager (Task 19, engine boot).
1309    /// Also mints ONE durable session-scope guard for the runtime's tool
1310    /// session, shared by every clone and stream; only the LAST owner's
1311    /// drop terminates leases.
1312    pub fn install_mcp_runtime(&mut self, manager: std::sync::Arc<crate::mcp::McpRuntimeManager>) {
1313        self.mcp_session_scope = Some(std::sync::Arc::new(crate::mcp::McpSessionEndGuard::new(
1314            self.host_tool_session.clone(),
1315            std::sync::Arc::clone(&manager),
1316        )));
1317        self.mcp_runtime = Some(manager);
1318    }
1319
1320    /// Set this runtime's per-turn budget (Task 23). The engine resolves
1321    /// role + typed config at boot; subagent constructions pass
1322    /// their role's budget explicitly.
1323    pub fn set_turn_budget(&mut self, budget: crate::runtime::budget::TurnBudget) {
1324        self.turn_budget = budget;
1325    }
1326
1327    /// Hand the next stream an [`OwnerSteerBudget`](crate::runtime::budget::OwnerSteerBudget)
1328    /// (`None` clears it). Like [`Self::set_turn_budget`], the stream takes it
1329    /// when it starts.
1330    pub fn set_owner_steer_budget(
1331        &mut self,
1332        lift: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
1333    ) {
1334        self.owner_steer_budget = lift;
1335    }
1336
1337    /// Where message times come from (tests inject a clock).
1338    pub fn message_clock(&self) -> &crate::runtime::message_time::MessageClock {
1339        &self.message_clock
1340    }
1341
1342    pub fn set_message_clock(&mut self, clock: crate::runtime::message_time::MessageClock) {
1343        self.message_clock = clock;
1344    }
1345
1346    /// The currently configured per-turn budget.
1347    pub fn turn_budget(&self) -> &crate::runtime::budget::TurnBudget {
1348        &self.turn_budget
1349    }
1350
1351    /// Install the shared exact EXTENSION lease manager (Task 20, engine
1352    /// boot). Also mints ONE durable session-scope guard for the runtime's
1353    /// tool session, shared by every clone and stream; only the LAST
1354    /// owner's drop terminates leases.
1355    pub fn install_extension_runtime(
1356        &mut self,
1357        manager: std::sync::Arc<crate::extensions::lease::ExtensionRuntimeManager>,
1358    ) {
1359        // Bind the handler host scope to THIS runtime's durable tool
1360        // session: a Mixed extension's tool leases and hook/provider/user
1361        // handler leases share one key — ONE shared child per plugin.
1362        manager.bind_host_scope(self.host_tool_session.clone());
1363        self.extension_session_scope = Some(std::sync::Arc::new(
1364            crate::extensions::lease::ExtensionSessionEndGuard::new(
1365                self.host_tool_session.clone(),
1366                std::sync::Arc::clone(&manager),
1367            ),
1368        ));
1369        self.extension_runtime = Some(manager);
1370    }
1371
1372    pub fn install_orchestration(
1373        &mut self,
1374        runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1375    ) {
1376        for model in &self.trusted_worker_models {
1377            if let Err(error) = runtime.grant_worker_model(model.as_str()) {
1378                tracing::warn!(
1379                    model = model.as_str(),
1380                    error = %error,
1381                    "failed to apply configured worker-model trust"
1382                );
1383            }
1384        }
1385        self.orchestration = Some(runtime);
1386    }
1387
1388    /// A runtime spawned for a worker shares only this session's bounded
1389    /// dispatch/tree authority. It must not replay process-global favorite
1390    /// models: doing so would turn a child construction into a fresh grant
1391    /// source. The child can dispatch only identities already authorized by
1392    /// this installed session policy.
1393    pub fn install_worker_orchestration(
1394        &mut self,
1395        runtime: Arc<crate::orchestration::OrchestrationRuntime>,
1396    ) {
1397        self.orchestration = Some(runtime);
1398    }
1399
1400    /// The `subagent.*` limits this runtime sizes delegation policies with.
1401    pub fn subagent_limits(&self) -> crate::config::SubagentLimits {
1402        self.subagent_limits
1403    }
1404
1405    pub fn set_delegation_parent(&mut self, parent: Option<String>) {
1406        self.delegation_parent = parent;
1407    }
1408
1409    pub fn orchestration(&self) -> Option<&Arc<crate::orchestration::OrchestrationRuntime>> {
1410        self.orchestration.as_ref()
1411    }
1412
1413    /// Extends the live session delegation policy with one explicitly
1414    /// user-trusted worker model (e.g. favorited mid-session in the models
1415    /// picker). Mid-session trust grants were always meant to be honored;
1416    /// the policy snapshot is not pinned against user decisions.
1417    pub fn grant_worker_model(&self, model: &str) -> std::result::Result<(), String> {
1418        self.orchestration
1419            .as_ref()
1420            .ok_or_else(|| "delegation policy unavailable".to_string())?
1421            .grant_worker_model(model)
1422    }
1423
1424    /// Infallible model change. Returns the reasoning clamp applied (if any)
1425    /// when the current level is not supported by the new model; errors from
1426    /// `try_set_model` are swallowed (model left unchanged).
1427    pub fn set_model(&mut self, model: String) -> Option<ReasoningClamp> {
1428        self.try_set_model(model).ok().flatten()
1429    }
1430
1431    /// Apply a model change while preserving orchestration lifecycle invariants.
1432    /// Returns an error instead of mutating either model or policy when active
1433    /// workers still require collection/reconciliation or when the replacement
1434    /// foreground cannot produce a trusted manifestless policy snapshot.
1435    /// On success, returns `Some(ReasoningClamp)` when an explicit reasoning
1436    /// level had to be substituted because the new model does not support it.
1437    pub fn try_set_model(
1438        &mut self,
1439        model: String,
1440    ) -> std::result::Result<Option<ReasoningClamp>, String> {
1441        // Older model pickers passed the rendered health row back here, e.g.
1442        // `✅  339ms  groq/llama-3.3-70b`. Remove that exact decoration shape,
1443        // rather than searching inside the ID: provider-qualified IDs may
1444        // legitimately contain `claude-` after their slash.
1445        let trimmed = model.trim();
1446        let cleaned = trimmed
1447            .split_once(char::is_whitespace)
1448            .and_then(|(_, rest)| {
1449                let rest = rest.trim_start();
1450                let (latency, candidate) = rest.split_once(char::is_whitespace)?;
1451                let millis = latency.strip_suffix("ms")?;
1452                if !millis.is_empty() && millis.chars().all(|c| c.is_ascii_digit()) {
1453                    let candidate = candidate.trim();
1454                    if !candidate.is_empty() && !candidate.chars().any(char::is_whitespace) {
1455                        return Some(candidate);
1456                    }
1457                }
1458                None
1459            })
1460            .unwrap_or(trimmed);
1461        // Manifestless orchestration snapshots are tied to the exact foreground
1462        // identity. Refuse the model mutation while a policy worker remains
1463        // unreconciled; replacing its registry would erase the completion-gate
1464        // remediation path, while changing only the runtime model would create a
1465        // stale authorization snapshot.
1466        if self.prompt_reload_source.is_none()
1467            && self
1468                .orchestration
1469                .as_ref()
1470                .is_some_and(|current| !current.unreconciled_runtime_handles().is_empty())
1471        {
1472            return Err("model change blocked: workers require collection/reconciliation".into());
1473        }
1474        let orchestration_replacement = if self.prompt_reload_source.is_none()
1475            && self.orchestration.is_some()
1476        {
1477            let foreground = crate::orchestration::canonical_foreground_identity(cleaned)
1478                .map_err(|_| "model change blocked: unresolved foreground model".to_string())?;
1479            let replacement = crate::orchestration::OrchestrationRuntime::from_limits(
1480                foreground,
1481                self.subagent_limits,
1482            )
1483            .map_err(|_| "model change blocked: trusted worker catalog unavailable".to_string())?;
1484            for trusted in &self.trusted_worker_models {
1485                replacement
1486                    .grant_worker_model(trusted.as_str())
1487                    .map_err(|_| {
1488                        "model change blocked: configured worker trust invalid".to_string()
1489                    })?;
1490            }
1491            Some(Arc::new(replacement))
1492        } else {
1493            None
1494        };
1495        self.model = cleaned.to_owned();
1496        // Replace the manifestless policy atomically after a safe model change so
1497        // inheritance and exact same-provider choices follow the new foreground.
1498        // Typed manifest policies are immutable and are not rewritten here.
1499        if let Some(replacement) = orchestration_replacement {
1500            self.orchestration = Some(replacement);
1501        }
1502        // Apply the new model's default reasoning level from exact capability
1503        // metadata (Codex catalog default, xAI documented default/Adaptive)
1504        // unless the user has explicitly chosen a level (explicit_reasoning is
1505        // true — set via command, config, or explicit session restore).
1506        // Derived/default levels are overwritten by the new model's default.
1507        if !self.explicit_reasoning {
1508            if let Some(level) =
1509                crate::runtime::openai::catalog::validation::default_level_for_model(cleaned)
1510            {
1511                self.set_reasoning_level(level);
1512            }
1513        }
1514        // An explicit level that the new model rejects would otherwise make
1515        // every subsequent turn fail pre-network until the user runs
1516        // `/thinking`. Substitute the nearest documented level instead, keeping
1517        // the explicit provenance flag so later switches still honor the user.
1518        Ok(self.clamp_reasoning_to_model())
1519    }
1520
1521    /// If the current effective reasoning level fails validation against the
1522    /// current model, replace it with the model's documented default (or the
1523    /// first documented option that validates). The `explicit_reasoning` flag
1524    /// is preserved. Returns the substitution performed, if any; leaves state
1525    /// untouched (and returns `None`) when nothing validates.
1526    fn clamp_reasoning_to_model(&mut self) -> Option<ReasoningClamp> {
1527        use crate::runtime::openai::catalog::validation::{
1528            default_level_for_model, thinking_options_for_model, validate_reasoning_mutation,
1529        };
1530        let model = self.model.as_str();
1531        let from = self.reasoning_level();
1532        if validate_reasoning_mutation(model, from).is_ok() {
1533            return None;
1534        }
1535        let to = default_level_for_model(model)
1536            .filter(|level| validate_reasoning_mutation(model, *level).is_ok())
1537            .or_else(|| {
1538                thinking_options_for_model(model)
1539                    .iter()
1540                    .filter_map(|opt| agent_core::reasoning::ReasoningLevel::parse(opt))
1541                    .find(|level| validate_reasoning_mutation(model, *level).is_ok())
1542            })?;
1543        let explicit = self.explicit_reasoning;
1544        self.set_reasoning_level(to);
1545        self.explicit_reasoning = explicit;
1546        Some(ReasoningClamp { from, to })
1547    }
1548
1549    pub fn set_tools(&mut self, tools: ToolRegistry) {
1550        self.tools = Arc::new(RwLock::new(tools));
1551    }
1552
1553    pub fn subagent_registry(&self) -> &Arc<Mutex<crate::runtime::subagent::SubagentRegistry>> {
1554        &self.subagent_registry
1555    }
1556
1557    pub fn event_queue(&self) -> &Arc<crate::events::EventQueue> {
1558        &self.event_queue
1559    }
1560
1561    /// Install a session-lifetime queue (B3 unpark: the queue outlives the
1562    /// `Runtime` so `synaps send` keeps resolving while parked). Must be
1563    /// called before the first turn — in-flight streams hold a clone.
1564    pub fn set_event_queue(&mut self, queue: Arc<crate::events::EventQueue>) {
1565        self.event_queue = queue;
1566    }
1567
1568    /// Get a shared reference to the extension hook bus.
1569    pub fn hook_bus(&self) -> &Arc<crate::extensions::hooks::HookBus> {
1570        &self.hook_bus
1571    }
1572
1573    /// Replace the hook bus before the first turn (in-flight streams keep
1574    /// the bus they started with). Subagent spawn only: the legacy
1575    /// fresh-runtime path installs the host's worker view, and
1576    /// `subagent_transform_hooks = off` detaches it.
1577    pub(crate) fn set_hook_bus(&mut self, bus: Arc<crate::extensions::hooks::HookBus>) {
1578        self.hook_bus = bus;
1579    }
1580
1581    /// Background shell/PTY sessions owned by this runtime (`Checkpoint`
1582    /// and `Parked` close them via `shutdown_all`).
1583    pub fn session_manager(&self) -> &Arc<crate::tools::shell::SessionManager> {
1584        &self.session_manager
1585    }
1586
1587    /// Runtime-scoped tool-session identity used by the stream execution
1588    /// gate (Task 16). Shared by clones (which share the tool registry);
1589    /// fresh per independently constructed `Runtime`.
1590    pub fn host_tool_session_id(&self) -> &crate::tools::activation::SessionId {
1591        &self.host_tool_session
1592    }
1593
1594    /// Conversation/session identity this runtime serves (keys hook
1595    /// injection). `None` = unkeyed (workers). Does not reset consent: linked
1596    /// compaction successors remain the same conversation. Use
1597    /// [`Self::begin_conversation`] for explicit new/resume boundaries.
1598    pub fn set_session_id(&mut self, id: Option<String>) {
1599        self.session_id = id;
1600    }
1601
1602    /// Start an explicitly new or resumed conversation with fresh Confirm consent.
1603    /// Revoke the old set first, so workers still running from the previous
1604    /// conversation stop auto-approving, then replace the Arc so old
1605    /// stream/worker clones cannot grant consent to the next conversation.
1606    /// Ordinary turns and linked compaction successors must not call this.
1607    pub fn begin_conversation(&mut self, id: Option<String>) {
1608        self.session_allow_all.revoke();
1609        self.session_allow_all = std::sync::Arc::new(crate::tools::SessionConfirmLatch::new());
1610        // New conversation, new cache-diagnosis chain (cleared in place so
1611        // shared driver/ping clones see it too).
1612        *self
1613            .last_msg_id
1614            .lock()
1615            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1616        self.set_session_id(id);
1617    }
1618
1619    pub fn session_id(&self) -> Option<&str> {
1620        self.session_id.as_deref()
1621    }
1622
1623    /// Per-session working directory handed to tools via
1624    /// `ToolCapabilities.cwd`. `None` = process cwd.
1625    pub fn set_cwd(&mut self, cwd: Option<PathBuf>) {
1626        self.cwd = cwd;
1627    }
1628
1629    pub fn cwd(&self) -> Option<&Path> {
1630        self.cwd.as_deref()
1631    }
1632
1633    /// Per-session environment snapshot handed to tools via
1634    /// `ToolCapabilities.env`. `None` = inherit process env.
1635    pub fn set_env(&mut self, env: Option<crate::session::types::SessionEnv>) {
1636        self.env = env.map(Into::into);
1637    }
1638
1639    /// [`Self::set_env`] with the forwarded secrets an owned session's tools
1640    /// also see (kept apart, merged at spawn).
1641    pub fn set_tool_env(&mut self, env: Option<crate::session::types::ToolEnv>) {
1642        self.env = env;
1643    }
1644
1645    pub fn set_env_stripped(&mut self, stripped: Vec<String>) {
1646        self.env_stripped = stripped;
1647    }
1648
1649    /// The session env snapshot (without any forwarded secrets).
1650    pub fn env(&self) -> Option<&crate::session::types::SessionEnv> {
1651        self.env.as_ref().map(|e| &e.vars)
1652    }
1653
1654    /// What a slash command typed in this chat is run with: this runtime's
1655    /// own id, folder, memory binding and tool environment. The one place a
1656    /// command gets them from, so it cannot be handed the host's.
1657    pub fn command_chat(&self) -> crate::skills::commands::CommandChat<'_> {
1658        crate::skills::commands::CommandChat {
1659            session_id: self.session_id(),
1660            cwd: self.cwd(),
1661            memory_backend: &self.memory_backend,
1662            env: self.env.as_ref(),
1663        }
1664    }
1665
1666    pub fn env_stripped(&self) -> &[String] {
1667        &self.env_stripped
1668    }
1669
1670    /// Test seam: the work phase and the note this chat's rollover state
1671    /// holds, as its last `context_checkpoint` left them.
1672    #[cfg(any(test, feature = "testing"))]
1673    pub fn context_checkpoint_for_harness(&self) -> (&'static str, String) {
1674        let state = self
1675            .continuation
1676            .lock()
1677            .unwrap_or_else(std::sync::PoisonError::into_inner);
1678        (state.policy.phase().as_str(), state.note.clone())
1679    }
1680
1681    /// Get a shared reference to the tool registry (for MCP lazy loading).
1682    pub fn tools_shared(&self) -> Arc<RwLock<ToolRegistry>> {
1683        Arc::clone(&self.tools)
1684    }
1685
1686    /// The registry's tool schema as this chat is offered it: without the
1687    /// tools its turns withhold (`chat_tools::withheld_tools`). The registry is
1688    /// the host's and holds every chat's tools, so its own
1689    /// [`ToolRegistry::tools_schema`] is not what any one chat sends.
1690    pub async fn offered_tools_schema(&self) -> Arc<Vec<Value>> {
1691        let withheld = chat_tools::withheld_tools(self.context_management_enabled());
1692        let registry = self.tools.read().await;
1693        registry
1694            .tools_schema_without(&withheld)
1695            .unwrap_or_else(|| registry.tools_schema())
1696    }
1697
1698    pub fn model(&self) -> &str {
1699        &self.model
1700    }
1701
1702    pub fn http_client(&self) -> &Client {
1703        &self.client
1704    }
1705
1706    /// Load provider capability evidence for `model` into THIS process before
1707    /// a model / reasoning mutation is validated against it. Only Meta Muse
1708    /// needs this: its exact effort set comes from a live, server-driven
1709    /// catalog held in a process-local cache, which a daemon-hosted session
1710    /// does not share with the TUI that listed the model. Uses this
1711    /// runtime's own credential source (local or remote broker); bounded,
1712    /// backed off, and never fails the caller.
1713    pub async fn prepare_model_capabilities(&self, model: &str) {
1714        if model.starts_with("muse/") {
1715            let broker = crate::auth::broker_from_source(
1716                &self.credential_source,
1717                &self.token_cache,
1718                self.client.clone(),
1719            );
1720            crate::runtime::openai::catalog::ensure_muse_catalog_for(model, &broker).await;
1721        }
1722    }
1723
1724    /// Whether this runtime projects a progressive (core-only) tool schema.
1725    pub fn progressive_tool_disclosure(&self) -> bool {
1726        self.progressive_tool_disclosure
1727    }
1728
1729    /// This conversation's exact tool activations as plain data, for the
1730    /// next runtime that serves it (daemon reload, park → unpark). The
1731    /// retained set's activations once a progressive turn ran; before that,
1732    /// whatever this runtime was itself handed and has not applied yet — so
1733    /// a reload or park before the first turn does not lose them either.
1734    pub fn carried_tool_activations(&self) -> Vec<crate::tools::activation::CarriedActivation> {
1735        let retained = self
1736            .retained_tool_set
1737            .lock()
1738            .unwrap_or_else(std::sync::PoisonError::into_inner)
1739            .clone();
1740        if let Some(shared) = retained {
1741            return shared
1742                .read()
1743                .unwrap_or_else(std::sync::PoisonError::into_inner)
1744                .carried_activations();
1745        }
1746        self.pending_tool_restore
1747            .lock()
1748            .unwrap_or_else(std::sync::PoisonError::into_inner)
1749            .clone()
1750            .unwrap_or_default()
1751    }
1752
1753    /// Hand this runtime the activations the same conversation held in its
1754    /// previous runtime. Nothing is granted here: they are re-validated and
1755    /// restored (or dropped and reported) at the first progressive turn
1756    /// start, against that moment's catalog and this runtime's
1757    /// `tools.activation_confirm` (see
1758    /// `runtime::stream::restore_pending_activations`). An empty list
1759    /// clears nothing that is already pending.
1760    pub fn restore_tool_activations(
1761        &self,
1762        carried: Vec<crate::tools::activation::CarriedActivation>,
1763    ) {
1764        if carried.is_empty() {
1765            return;
1766        }
1767        let mut pending = self
1768            .pending_tool_restore
1769            .lock()
1770            .unwrap_or_else(std::sync::PoisonError::into_inner);
1771        match pending.as_mut() {
1772            Some(list) => {
1773                for c in carried {
1774                    if !list.iter().any(|p| p.tool_id == c.tool_id) {
1775                        list.push(c);
1776                    }
1777                }
1778            }
1779            None => *pending = Some(carried),
1780        }
1781    }
1782
1783    /// `tools.activation_confirm` policy in force for this runtime.
1784    pub fn activation_confirm(&self) -> agent_core::config::ActivationConfirm {
1785        self.activation_confirm
1786    }
1787
1788    /// Override the `tools.activation_confirm` policy (hosts/tests that do
1789    /// not go through `apply_config`).
1790    pub fn set_activation_confirm(&mut self, mode: agent_core::config::ActivationConfirm) {
1791        self.activation_confirm = mode;
1792    }
1793
1794    /// Identity of the underlying connection pool: two `Client` handles that
1795    /// clone the same `Arc` share one pool. `reqwest::Client` is a single
1796    /// `Arc<ClientRef>`, so its bytes ARE the pool pointer.
1797    #[cfg(any(test, feature = "testing"))]
1798    pub fn http_client_pool_id(&self) -> usize {
1799        const _: () = assert!(std::mem::size_of::<Client>() == std::mem::size_of::<usize>());
1800        // SAFETY: size asserted above; only the bit pattern is read, no
1801        // ownership is taken (transmute_copy does not move out of `self`).
1802        unsafe { std::mem::transmute_copy::<Client, usize>(&self.client) }
1803    }
1804    pub fn set_thinking_budget(&mut self, budget: u32) {
1805        self.thinking_budget = budget;
1806        // Sync named_level from budget so the two fields stay consistent.
1807        self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1808            budget,
1809        ));
1810        // Config/restore path — not an explicit user choice.
1811        self.explicit_reasoning = false;
1812    }
1813
1814    /// Set the named reasoning level (config/restore path). Updates
1815    /// `thinking_budget` from the level's canonical budget when one exists;
1816    /// for Max/Ultra (no numeric budget), leaves `thinking_budget` unchanged.
1817    /// Marks `explicit_reasoning = false` — use `set_reasoning_level_explicit`
1818    /// for user commands and settings.
1819    pub fn set_reasoning_level(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1820        self.named_level = Some(level);
1821        if let Some(budget) = level.to_legacy_budget() {
1822            self.thinking_budget = budget;
1823        }
1824        // Max/Ultra: do NOT overwrite thinking_budget with u32::MAX.
1825        self.explicit_reasoning = false;
1826    }
1827
1828    /// Set the named reasoning level as an **explicit user choice** (slash
1829    /// commands, settings panel). Identical to `set_reasoning_level` but sets
1830    /// `explicit_reasoning = true` so that a subsequent `set_model` call does
1831    /// not overwrite the level with the new model's default.
1832    pub fn set_reasoning_level_explicit(&mut self, level: agent_core::reasoning::ReasoningLevel) {
1833        self.set_reasoning_level(level);
1834        self.explicit_reasoning = true;
1835    }
1836
1837    /// Restore a saved session's `thinking_level` string as an explicit user
1838    /// choice, then clamp it against the current model. Call **after**
1839    /// `set_model(session.model)`: re-applying the saved level would otherwise
1840    /// undo the clamp `set_model` performed (old grok+xhigh session files).
1841    /// Returns the clamp applied, if any, so callers can surface it.
1842    pub fn restore_session_reasoning(&mut self, thinking_level: &str) -> Option<ReasoningClamp> {
1843        if let Some(level) = agent_core::reasoning::ReasoningLevel::parse(thinking_level) {
1844            self.set_reasoning_level_explicit(level);
1845        } else if let Some(budget) = crate::models::budget_for_thinking_level(thinking_level) {
1846            self.set_thinking_budget_explicit(budget);
1847        } else {
1848            return None;
1849        }
1850        self.clamp_reasoning_to_model()
1851    }
1852
1853    /// Set a custom numeric thinking budget as an **explicit user choice**
1854    /// (e.g. `/thinking 8192`). Retains the exact budget in `thinking_budget`
1855    /// while syncing `named_level` to the nearest named level for display.
1856    /// Sets `explicit_reasoning = true`.
1857    pub fn set_thinking_budget_explicit(&mut self, budget: u32) {
1858        self.thinking_budget = budget;
1859        self.named_level = Some(agent_core::reasoning::ReasoningLevel::from_legacy_budget(
1860            budget,
1861        ));
1862        self.explicit_reasoning = true;
1863    }
1864
1865    /// Expose the `explicit_reasoning` provenance flag (test/introspection).
1866    pub fn is_reasoning_explicit(&self) -> bool {
1867        self.explicit_reasoning
1868    }
1869
1870    /// Raw thinking budget value (for testing and legacy request building).
1871    pub fn thinking_budget_raw(&self) -> u32 {
1872        self.thinking_budget
1873    }
1874
1875    /// Validate `level` against the current model's capability metadata via
1876    /// the shared per-provider validator (cache then exact static tables),
1877    /// then apply it. Returns `Err(user-facing message)` and leaves runtime
1878    /// state unchanged if the level is unsupported.
1879    pub fn set_reasoning_level_checked(
1880        &mut self,
1881        level: agent_core::reasoning::ReasoningLevel,
1882    ) -> std::result::Result<(), String> {
1883        crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1884            &self.model,
1885            level,
1886        )?;
1887        if self.model.starts_with("anthropic/")
1888            && level == agent_core::reasoning::ReasoningLevel::UltraCode
1889        {
1890            if self.codex_request_role()
1891                != crate::runtime::openai::catalog::ExecutionRole::Foreground
1892            {
1893                return Err("ultracode_requires_foreground".into());
1894            }
1895            if self.effective_prompt.is_some() {
1896                return Err("typed_prompt_manifest_blocks_required_doctrine".into());
1897            }
1898            if self.orchestration.is_none() {
1899                return Err("ultracode_requires_orchestration".into());
1900            }
1901            let tools = self.tools.try_read().map_err(|_| {
1902                "ultracode prerequisite state is busy; refusing mutation".to_string()
1903            })?;
1904            let required = ["subagent_start", "subagent_status", "subagent_collect"];
1905            if !required.iter().all(|name| {
1906                tools
1907                    .get(name)
1908                    .is_some_and(|tool| tool.extension_id().is_none())
1909            }) {
1910                return Err("ultracode_requires_lifecycle_tools".into());
1911            }
1912        }
1913        self.set_reasoning_level_explicit(level);
1914        Ok(())
1915    }
1916
1917    /// Validate then apply a custom numeric thinking budget (`/thinking <N>`).
1918    /// The budget's derived `ReasoningLevel` runs through the same exact-model
1919    /// mutation validator as named levels (Anthropic fixed-budget models pass:
1920    /// derived levels never exceed XHigh and thinking-capable descriptors
1921    /// accept them). On `Ok` the exact budget is retained — never downgraded
1922    /// to a named-level canonical budget. On `Err` state is unchanged.
1923    pub fn set_thinking_budget_checked(&mut self, budget: u32) -> std::result::Result<(), String> {
1924        let level = agent_core::reasoning::ReasoningLevel::from_legacy_budget(budget);
1925        crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
1926            &self.model,
1927            level,
1928        )?;
1929        self.set_thinking_budget_explicit(budget);
1930        Ok(())
1931    }
1932
1933    pub fn reasoning_level(&self) -> agent_core::reasoning::ReasoningLevel {
1934        self.named_level.unwrap_or_else(|| {
1935            agent_core::reasoning::ReasoningLevel::from_legacy_budget(self.thinking_budget)
1936        })
1937    }
1938
1939    pub(crate) fn set_codex_request_role(
1940        &mut self,
1941        role: crate::runtime::openai::catalog::CodexRequestRole,
1942    ) {
1943        self.codex_request_role = role;
1944    }
1945
1946    pub(crate) fn codex_request_role(&self) -> crate::runtime::openai::catalog::CodexRequestRole {
1947        self.codex_request_role
1948    }
1949
1950    pub(crate) fn codex_delegation_plan(
1951        model: &str,
1952        level: agent_core::reasoning::ReasoningLevel,
1953        role: crate::runtime::openai::catalog::CodexRequestRole,
1954    ) -> Option<crate::runtime::openai::catalog::CodexExecutionPlan> {
1955        use crate::runtime::openai::catalog::{plan_codex_execution, CodexRequestRole};
1956        if role != CodexRequestRole::Foreground
1957            || level != agent_core::reasoning::ReasoningLevel::Ultra
1958            || !model.starts_with("openai-codex/")
1959        {
1960            return None;
1961        }
1962        plan_codex_execution(model, level, role, None).ok()
1963    }
1964
1965    async fn authorized_anthropic_plan(
1966        &self,
1967    ) -> Result<Option<crate::runtime::openai::catalog::AnthropicExecutionPlan>> {
1968        if !self.model.starts_with("anthropic/") {
1969            return Ok(None);
1970        }
1971        let tools = self.tools.read().await;
1972        let builtin = |name: &str| {
1973            tools
1974                .get(name)
1975                .is_some_and(|tool| tool.extension_id().is_none())
1976        };
1977        let readiness = self
1978            .orchestration
1979            .as_ref()
1980            .and_then(|runtime| runtime.ultracode_readiness(&self.model).ok());
1981        let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
1982            orchestration_policy: self.orchestration.is_some(),
1983            foreground_worker_authorized: readiness.is_some(),
1984            concurrent_limit: readiness.map_or(0, |limits| limits.0),
1985            total_limit: readiness.map_or(0, |limits| limits.1),
1986            lifecycle_start: builtin("subagent_start"),
1987            lifecycle_status: builtin("subagent_status"),
1988            lifecycle_steer: builtin("subagent_steer"),
1989            lifecycle_collect: builtin("subagent_collect"),
1990            lifecycle_resume: builtin("subagent_resume"),
1991        };
1992        crate::runtime::openai::catalog::plan_anthropic_execution(
1993            &self.model,
1994            self.reasoning_level(),
1995            self.codex_request_role(),
1996            prerequisites,
1997            crate::runtime::openai::catalog::capability_cache::get(&self.model).and_then(|entry| {
1998                match entry.reasoning {
1999                    crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2000                        adaptive,
2001                    } => Some(adaptive),
2002                    crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2003                    _ => None,
2004                }
2005            }),
2006        )
2007        .map(Some)
2008        .map_err(|error| RuntimeError::Config(error.to_string()))
2009    }
2010
2011    /// Validate exact-model reasoning and Ultra orchestration prerequisites
2012    /// before refresh, broker access, or provider network work.
2013    async fn validate_request_preflight(&self) -> Result<()> {
2014        self.validate_request_preflight_for(&self.model, self.codex_request_role)
2015            .await
2016    }
2017
2018    /// Session-driver preflight: same validation as request preflight.
2019    /// merge(112): added for session_driver.rs; thin delegation.
2020    pub(crate) async fn validate_session_driver_preflight(&self) -> Result<()> {
2021        self.validate_request_preflight().await
2022    }
2023
2024    async fn validate_request_preflight_for(
2025        &self,
2026        model: &str,
2027        role: crate::runtime::openai::catalog::CodexRequestRole,
2028    ) -> Result<()> {
2029        let level = self.reasoning_level();
2030        if model.starts_with("anthropic/")
2031            && level == agent_core::reasoning::ReasoningLevel::UltraCode
2032            && self.effective_prompt.is_some()
2033        {
2034            return Err(RuntimeError::Config(
2035                "Anthropic execution plan rejected: typed_prompt_manifest_blocks_required_doctrine"
2036                    .into(),
2037            ));
2038        }
2039        if model.starts_with("anthropic/")
2040            && matches!(
2041                level,
2042                agent_core::reasoning::ReasoningLevel::Max
2043                    | agent_core::reasoning::ReasoningLevel::UltraCode
2044                    | agent_core::reasoning::ReasoningLevel::Ultra
2045            )
2046        {
2047            let tools = self.tools.read().await;
2048            let builtin = |name: &str| {
2049                tools
2050                    .get(name)
2051                    .is_some_and(|tool| tool.extension_id().is_none())
2052            };
2053            let readiness = self
2054                .orchestration
2055                .as_ref()
2056                .and_then(|runtime| runtime.ultracode_readiness(model).ok());
2057            let prerequisites = crate::runtime::openai::catalog::AnthropicPlanPrerequisites {
2058                orchestration_policy: self.orchestration.is_some(),
2059                foreground_worker_authorized: readiness.is_some(),
2060                concurrent_limit: readiness.map_or(0, |limits| limits.0),
2061                total_limit: readiness.map_or(0, |limits| limits.1),
2062                lifecycle_start: builtin("subagent_start"),
2063                lifecycle_status: builtin("subagent_status"),
2064                lifecycle_steer: builtin("subagent_steer"),
2065                lifecycle_collect: builtin("subagent_collect"),
2066                lifecycle_resume: builtin("subagent_resume"),
2067            };
2068            drop(tools);
2069            let result = crate::runtime::openai::catalog::plan_anthropic_execution(
2070                model,
2071                level,
2072                role,
2073                prerequisites,
2074                crate::runtime::openai::catalog::capability_cache::get(model).and_then(|entry| {
2075                    match entry.reasoning {
2076                        crate::runtime::openai::catalog::ReasoningSupport::AnthropicAdaptive {
2077                            adaptive,
2078                        } => Some(adaptive),
2079                        crate::runtime::openai::catalog::ReasoningSupport::None => Some(false),
2080                        _ => None,
2081                    }
2082                }),
2083            );
2084            match result {
2085                Ok(plan) => {
2086                    tracing::debug!(event = "anthropic_mode_plan", qualified_model = %model, requested_level = %level, runtime_role = role.as_str(), execution_mode = ?plan.mode, wire_effort = plan.wire_effort.map_or("omitted", |effort| effort.as_str()), workflow = ?plan.workflow, credentials_attempted = false, network_attempted = false);
2087                    return Ok(());
2088                }
2089                Err(error) => {
2090                    tracing::debug!(event = "anthropic_mode_plan", qualified_model = %model, requested_level = %level, runtime_role = role.as_str(), decision = "deny", deny_code = error.code().as_str(), credentials_attempted = false, network_attempted = false);
2091                    return Err(RuntimeError::Config(error.to_string()));
2092                }
2093            }
2094        }
2095        if model.starts_with("openai-codex/") {
2096            let plan =
2097                crate::runtime::openai::catalog::plan_codex_execution(model, level, role, None)
2098                    .map_err(|error| {
2099                        tracing::debug!(
2100                            event = "codex_mode_plan",
2101                            qualified_model = %model,
2102                            requested_level = %level,
2103                            runtime_role = role.as_str(),
2104                            decision = "deny",
2105                            deny_code = error.code().as_str(),
2106                            network_attempted = false,
2107                            "Codex request preflight denied"
2108                        );
2109                        RuntimeError::Config(error.to_string())
2110                    })?;
2111
2112            if plan.automatic_delegation() {
2113                if self.orchestration.is_none() {
2114                    tracing::debug!(
2115                        event = "codex_mode_plan",
2116                        qualified_model = %model,
2117                        requested_level = %level,
2118                        runtime_role = role.as_str(),
2119                        decision = "deny",
2120                        deny_code = "ultra_requires_orchestration",
2121                        network_attempted = false,
2122                        "Codex request preflight denied"
2123                    );
2124                    return Err(RuntimeError::Config(
2125                        "Ultra requires an installed orchestration policy".to_string(),
2126                    ));
2127                }
2128
2129                let tools = self.tools.read().await;
2130                let required = ["subagent_start", "subagent_status", "subagent_collect"];
2131                let tools_ready = required.iter().all(|name| {
2132                    tools
2133                        .get(name)
2134                        .is_some_and(|tool| tool.extension_id().is_none())
2135                });
2136                if !tools_ready {
2137                    tracing::debug!(
2138                        event = "codex_mode_plan",
2139                        qualified_model = %model,
2140                        requested_level = %level,
2141                        runtime_role = role.as_str(),
2142                        decision = "deny",
2143                        deny_code = "ultra_requires_subagent_tools",
2144                        network_attempted = false,
2145                        "Codex request preflight denied"
2146                    );
2147                    return Err(RuntimeError::Config(
2148                        "Ultra requires built-in subagent_start, subagent_status, and subagent_collect tools"
2149                            .to_string(),
2150                    ));
2151                }
2152            }
2153            return Ok(());
2154        }
2155
2156        crate::runtime::openai::catalog::validation::validate_reasoning_mutation(model, level)
2157            .map_err(|error| {
2158                tracing::debug!(
2159                    event = "codex_mode_plan",
2160                    qualified_model = %model,
2161                    requested_level = %level,
2162                    runtime_role = role.as_str(),
2163                    decision = "deny",
2164                    deny_code = "unsupported_reasoning_level",
2165                    network_attempted = false,
2166                    "request preflight denied"
2167                );
2168                RuntimeError::Config(error)
2169            })
2170    }
2171
2172    pub fn set_compaction_model(&mut self, model: Option<String>) {
2173        self.compaction_model = model;
2174    }
2175
2176    /// Set where compaction summarization runs (spec §9.4).
2177    pub fn set_compaction_mode(&mut self, mode: agent_core::compaction::CompactionMode) {
2178        self.compaction_mode = mode;
2179    }
2180
2181    /// Set the content classes withheld from remote compaction disclosure.
2182    pub fn set_compaction_exclusions(
2183        &mut self,
2184        exclude: Vec<agent_core::compaction::ContentClass>,
2185    ) {
2186        self.compaction_exclusions = exclude;
2187    }
2188
2189    /// Number of times the remote-summarization transport seam was entered
2190    /// (CP-12 M3). Local-only compaction must leave this at zero.
2191    pub fn remote_summarization_attempts(&self) -> u64 {
2192        self.remote_summarization_attempts
2193            .load(std::sync::atomic::Ordering::SeqCst)
2194    }
2195
2196    /// The session's compaction disclosure policy (spec §9.4) — consumed by
2197    /// `runtime::compaction` for both the preview and the dispatch path.
2198    pub fn compaction_policy(&self) -> crate::runtime::compaction::DisclosurePolicy {
2199        crate::runtime::compaction::DisclosurePolicy {
2200            mode: self.compaction_mode,
2201            exclude: self.compaction_exclusions.clone(),
2202        }
2203    }
2204
2205    pub fn set_context_window(&mut self, window: Option<u64>) {
2206        self.context_window_override = window;
2207    }
2208
2209    /// The configured window override (`None` = the model default).
2210    pub fn context_window_override(&self) -> Option<u64> {
2211        self.context_window_override
2212    }
2213
2214    /// Effective context window for the current model — user override if set,
2215    /// otherwise the model's native window from `models::context_window_for_model`.
2216    pub fn compaction_model(&self) -> &str {
2217        self.compaction_model
2218            .as_deref()
2219            .unwrap_or("claude-sonnet-4-6")
2220    }
2221
2222    pub fn context_window(&self) -> u64 {
2223        self.context_window_override
2224            .unwrap_or_else(|| crate::models::context_window_for_model(&self.model))
2225    }
2226
2227    /// Task 29 (spec §9.1): the ONE request-aware context assessment every
2228    /// frontend consumes on its compaction trigger path. Reads the segments
2229    /// the next request will actually carry — the effective system prompt,
2230    /// the exposed tool-schema set, the supplied history — and the runtime's
2231    /// configured reserves (thinking budget, tool-result cap, the expected
2232    /// output within the model's `max_tokens`, provider window with the
2233    /// documented safety margin).
2234    ///
2235    /// Skill bodies that reach the request do so through the system prompt
2236    /// or history today, so they are already accounted there; the skill lane
2237    /// is fed once loaders hand the engine distinct segments. Nothing in
2238    /// core feeds the memory lane any more: it stays empty.
2239    pub async fn assess_context(
2240        &self,
2241        messages: &[crate::SharedMessage],
2242    ) -> context::ContextAssessment {
2243        let system = self.effective_system_prompt().await;
2244        let schema = self.offered_tools_schema().await;
2245        let anchor = self
2246            .continuation
2247            .lock()
2248            .unwrap_or_else(std::sync::PoisonError::into_inner)
2249            .usage_anchor_for(&self.model)
2250            .cloned();
2251        context::assess(&context::ContextBudgetInputs {
2252            model: &self.model,
2253            provider_window: self.context_window(),
2254            system_prompt: system.as_deref(),
2255            tools_schema: &schema,
2256            messages,
2257            skill_contents: &[],
2258            memory_contents: &[],
2259            thinking_budget_tokens: self.thinking_budget as u64,
2260            next_tool_result_bytes: self.max_tool_output as u64,
2261            max_output_tokens: HelperMethods::max_tokens_for_model(&self.model),
2262        })
2263        .anchored(anchor.as_ref(), messages)
2264    }
2265
2266    /// Give a worker its parent's binding: the parent's base and project,
2267    /// wherever the worker's own folder is, with a fresh worker execution
2268    /// author. Oneshot, start, and resume share this path; even resume must
2269    /// not reuse a prior actor. The runtime is then bound: a later
2270    /// `apply_config` on it does not replace the binding with one for the
2271    /// worker's folder. Tool grants stay untouched.
2272    pub(crate) fn inherit_memory_binding(&mut self, binding: &crate::memory_backend::MemoryBinding) {
2273        self.memory_backend = binding.for_worker();
2274        self.memory_bound = true;
2275    }
2276
2277    /// Bind this runtime's memory to the project of its folder, once: a
2278    /// runtime that is already bound keeps its binding. Standalone host
2279    /// entry points call it without applying unrelated settings;
2280    /// `apply_config` calls it too, so a runtime is given its folder
2281    /// (`set_cwd`) before its first `apply_config`.
2282    pub fn bind_memory(&mut self) {
2283        if self.memory_bound {
2284            return;
2285        }
2286        // merge(112) §4: under the daemon the session cwd differs from
2287        // process cwd; use it when available so project-scope resolution
2288        // lands in the right repo.
2289        self.memory_backend = crate::memory_backend::MemoryBinding::for_cwd(
2290            self.cwd.clone().or_else(|| std::env::current_dir().ok()),
2291        );
2292        self.memory_bound = true;
2293    }
2294
2295    #[cfg(test)]
2296    #[allow(dead_code)] // merge(112): consumed in phase 5
2297    pub(crate) fn memory_backend_for_test(&self) -> crate::memory_backend::MemoryBinding {
2298        self.memory_backend.clone()
2299    }
2300
2301    /// Apply a parsed config file to this runtime (model, thinking budget, etc.)
2302    /// Includes the `disabled_tools` pass on this runtime's registry — the
2303    /// fresh-`Runtime::new()` path, where the registry holds builtins only.
2304    pub fn apply_config(&mut self, config: &crate::config::SynapsConfig) {
2305        self.apply_config_inner(config, true);
2306    }
2307
2308    /// `apply_config` WITHOUT the `disabled_tools` pass. Used by
2309    /// `EngineHost::foreground_runtime`: the host already disabled builtins
2310    /// on the fresh registry before skills/MCP registered (the old boot
2311    /// point), and a second pass here would also strip `load_skill`,
2312    /// `search_skills` and the descriptor-backed MCP tools — which
2313    /// the old boot never did.
2314    pub(crate) fn apply_config_keep_tools(&mut self, config: &crate::config::SynapsConfig) {
2315        self.apply_config_inner(config, false);
2316    }
2317
2318    fn apply_config_inner(&mut self, config: &crate::config::SynapsConfig, disable_tools: bool) {
2319        // #112: bind memory early (the binding is immutable once made).
2320        self.bind_memory();
2321        if let Some(ref model) = config.model {
2322            self.set_model(model.clone());
2323        }
2324        // Named level takes priority over raw budget.
2325        // Config-specified thinking is an explicit user choice — preserve across model switches.
2326        if let Some(level) = config.thinking_level {
2327            self.set_reasoning_level_explicit(level);
2328        } else if let Some(budget) = config.thinking_budget {
2329            self.set_thinking_budget_explicit(budget);
2330        }
2331        // A configured level the configured model rejects would boot into a
2332        // permanently failing state; clamp exactly as `/model` does.
2333        if let Some(clamp) = self.clamp_reasoning_to_model() {
2334            tracing::warn!(
2335                from = clamp.from.as_str(),
2336                to = clamp.to.as_str(),
2337                model = %self.model,
2338                "configured reasoning level not supported by model; clamped"
2339            );
2340        }
2341        self.context_window_override = config.context_window;
2342        self.continuation
2343            .lock()
2344            .unwrap_or_else(std::sync::PoisonError::into_inner)
2345            .config = config.context_management;
2346        self.compaction_model = config.compaction_model.clone();
2347        self.compaction_mode = config.compaction_mode;
2348        self.compaction_exclusions = config.compaction_exclude.clone();
2349        self.max_tool_output = config.max_tool_output;
2350        self.tool_spill_policy = tool_spill::ToolSpillPolicy::from_config(config);
2351        self.read_dedupe = config.read_dedupe;
2352        self.thinking_display = config.thinking_display;
2353        self.tool_keepwarm = config.cache_keepwarm_tools;
2354        self.bash_timeout = config.bash_timeout;
2355        self.bash_max_timeout = config.bash_max_timeout;
2356        self.subagent_timeout = config.subagent_timeout;
2357        self.api_retries = config.api_retries;
2358        self.refusal_retries = config.refusal_retries;
2359        self.telemetry_level =
2360            crate::runtime::telemetry::TelemetryLevel::from_str_key(&config.telemetry);
2361        self.telemetry_log_generations = config.telemetry_log_generations;
2362        self.sync_observability();
2363        self.cache_diagnostics = config.cache_diagnostics;
2364        self.cache_ttl = config.cache_ttl;
2365        self.request_compression = request_encoding::RequestCompression::from_config(
2366            config.request_compression,
2367            config.request_compression_level,
2368        );
2369        self.progressive_tool_disclosure = config.progressive_tool_disclosure;
2370        self.tools_always_loaded = Arc::from(config.tools_always_loaded.as_slice());
2371        self.deferred_tool_loading = config.tools_deferred_loading;
2372        self.activation_confirm = config.tools_activation_confirm;
2373        tracing::info!(
2374            mode = config.tools_activation_confirm.as_str(),
2375            auto_approve_confirms = config.server.auto_approve_confirms,
2376            "tools.activation_confirm: model-initiated activate_tools policy"
2377        );
2378        self.trusted_worker_models = config
2379            .favorite_models
2380            .iter()
2381            .filter_map(|model| {
2382                let canonical = canonical_trusted_worker_model(model);
2383                match agent_core::prompt::QualifiedModelId::parse(canonical) {
2384                    Ok(model) => Some(model),
2385                    Err(_) => {
2386                        tracing::warn!(
2387                            model = model.as_str(),
2388                            "ignoring invalid favorite worker-model identity"
2389                        );
2390                        None
2391                    }
2392                }
2393            })
2394            .collect();
2395        self.trusted_worker_models.sort();
2396        self.trusted_worker_models.dedup();
2397        self.subagent_limits = config.subagent;
2398        if let Some(orchestration) = &self.orchestration {
2399            for model in &self.trusted_worker_models {
2400                if let Err(error) = orchestration.grant_worker_model(model.as_str()) {
2401                    tracing::warn!(
2402                        model = model.as_str(),
2403                        error = %error,
2404                        "failed to apply configured worker-model trust"
2405                    );
2406                }
2407            }
2408        }
2409        self.apply_auth_config(config);
2410
2411        // Remove any built-in tools the user disabled via `disabled_tools`.
2412        // try_write is safe here: this runs on a fresh runtime's private
2413        // registry before it is shared with other tasks.
2414        if disable_tools && !config.disabled_tools.is_empty() {
2415            if let Ok(mut reg) = self.tools.try_write() {
2416                reg.disable(&config.disabled_tools);
2417            }
2418        }
2419    }
2420
2421    /// Apply only the credential-source portion of config. Used by subagent
2422    /// spawns that build a fresh `Runtime::new()` (which defaults to Local) and
2423    /// must inherit the user's Remote/broker setup. (#158 A3)
2424    ///
2425    /// A6: invalidates the token cache if the source/endpoint changed.
2426    /// A2: scrubs `AuthState` when Remote so no local `auth.json` credential is
2427    /// used or held (invariant 1).
2428    pub fn apply_auth_config(&mut self, config: &crate::config::SynapsConfig) {
2429        let new_source = config.auth.credential_source();
2430        if new_source != self.credential_source {
2431            self.token_cache.invalidate("anthropic");
2432        }
2433        self.credential_source = new_source;
2434        if self.credential_source.is_remote() {
2435            if let Ok(mut auth) = self.auth.try_write() {
2436                AuthMethods::scrub_for_remote(&mut auth);
2437            }
2438        }
2439        // Account switch (G5): if the in-memory Anthropic OAuth token is
2440        // bound to a credential other than the one this config now selects
2441        // (`auth.account.anthropic` / `SYNAPS_ACCOUNT_ANTHROPIC`, or a
2442        // source change), drop it so the next turn re-vends through the
2443        // broker — the previous account is never silently kept. Stateless:
2444        // compares the token's recorded binding to the new selection.
2445        if let Ok(mut auth) = self.auth.try_write() {
2446            if auth.auth_type == "oauth" && !auth.auth_token.is_empty() {
2447                let selector = config
2448                    .auth
2449                    .account_selector(crate::auth::OAuthProviderId::Anthropic);
2450                let check = crate::runtime::auth::check_binding(
2451                    &self.credential_source,
2452                    &selector,
2453                    auth.bound_credential.as_deref(),
2454                    crate::runtime::auth::RefreshPoint::TurnStart,
2455                );
2456                if check != crate::runtime::auth::BindingCheck::Matches {
2457                    AuthMethods::scrub_for_account_switch(&mut auth);
2458                    self.token_cache.invalidate("anthropic");
2459                }
2460            }
2461        }
2462        // Install the process-wide credential broker matching this source so
2463        // every request path (streams, pings, catalog, TUI status) resolves
2464        // credentials through the same boundary. Local sources get the
2465        // in-process broker — no separately launched daemon required.
2466        crate::auth::set_global_broker(crate::auth::broker_from_source(
2467            &self.credential_source,
2468            &self.token_cache,
2469            self.client.clone(),
2470        ));
2471    }
2472
2473    pub fn thinking_budget(&self) -> u32 {
2474        self.thinking_budget
2475    }
2476
2477    pub fn max_tool_output(&self) -> usize {
2478        self.max_tool_output
2479    }
2480
2481    pub fn bash_timeout(&self) -> u64 {
2482        self.bash_timeout
2483    }
2484
2485    pub fn bash_max_timeout(&self) -> u64 {
2486        self.bash_max_timeout
2487    }
2488
2489    pub fn subagent_timeout(&self) -> u64 {
2490        self.subagent_timeout
2491    }
2492
2493    pub fn api_retries(&self) -> u32 {
2494        self.api_retries
2495    }
2496
2497    pub fn set_max_tool_output(&mut self, v: usize) {
2498        self.max_tool_output = v;
2499    }
2500
2501    pub fn tool_spill_policy(&self) -> tool_spill::ToolSpillPolicy {
2502        self.tool_spill_policy
2503    }
2504
2505    pub fn set_tool_spill_policy(&mut self, policy: tool_spill::ToolSpillPolicy) {
2506        self.tool_spill_policy = policy;
2507    }
2508
2509    pub fn read_dedupe(&self) -> bool {
2510        self.read_dedupe
2511    }
2512
2513    /// Read dedupe + write/edit notes; off = legacy tool-result bytes.
2514    pub fn set_read_dedupe(&mut self, enabled: bool) {
2515        self.read_dedupe = enabled;
2516    }
2517
2518    /// The configured `thinking_display` (whatever this runtime's role).
2519    pub fn thinking_display(&self) -> crate::config::ThinkingDisplay {
2520        self.thinking_display
2521    }
2522
2523    pub fn set_thinking_display(&mut self, display: crate::config::ThinkingDisplay) {
2524        self.thinking_display = display;
2525    }
2526
2527    /// The `thinking.display` this runtime's requests send: the configured
2528    /// value on the foreground session; always summarized for a worker or an
2529    /// internal request. A worker's thinking summaries are its record: they
2530    /// stream into `/watch`, and its rollover transcripts and the parent's
2531    /// archived worker history keep them for review, and no one is
2532    /// waiting on its first text token.
2533    pub(crate) fn wire_thinking_display(&self) -> crate::config::ThinkingDisplay {
2534        match self.codex_request_role {
2535            crate::runtime::openai::catalog::CodexRequestRole::Foreground => self.thinking_display,
2536            _ => crate::config::ThinkingDisplay::Summarized,
2537        }
2538    }
2539
2540    pub fn tool_keepwarm(&self) -> bool {
2541        self.tool_keepwarm
2542    }
2543
2544    /// Keep the cache warm while a tool round runs (`cache_keepwarm_tools`).
2545    pub fn set_tool_keepwarm(&mut self, enabled: bool) {
2546        self.tool_keepwarm = enabled;
2547    }
2548
2549    /// Session totals of tool output virtualisation (count, bytes kept out
2550    /// of history).
2551    pub fn tool_spill_totals(&self) -> tool_spill::ToolSpillTotals {
2552        self.tool_spill_stats.totals()
2553    }
2554
2555    /// The virtualiser one stream uses. Its dir is keyed by the persisted
2556    /// conversation id, so retention removes it with the session; a worker
2557    /// (no conversation id) is keyed by its own runtime trace session.
2558    pub(crate) fn tool_spill(&self) -> tool_spill::ToolSpill {
2559        if !self.tool_spill_policy.is_enabled() {
2560            return tool_spill::ToolSpill::off();
2561        }
2562        let key = self
2563            .session_id
2564            .clone()
2565            .unwrap_or_else(|| self.trace_ctx.session_id().as_str().to_string());
2566        tool_spill::ToolSpill::new(
2567            self.tool_spill_policy,
2568            &self.tool_spill_root,
2569            &key,
2570            Arc::clone(&self.tool_spill_stats),
2571        )
2572    }
2573
2574    pub fn set_bash_timeout(&mut self, v: u64) {
2575        self.bash_timeout = v;
2576    }
2577
2578    pub fn set_bash_max_timeout(&mut self, v: u64) {
2579        self.bash_max_timeout = v;
2580    }
2581
2582    pub fn set_subagent_timeout(&mut self, v: u64) {
2583        self.subagent_timeout = v;
2584    }
2585
2586    /// Copy only retry policy; worker construction must not re-install brokers
2587    /// or acquire foreground capabilities by calling full apply_config.
2588    pub(crate) fn apply_retry_config(&mut self, config: &crate::SynapsConfig) {
2589        self.api_retries = config.api_retries;
2590        self.refusal_retries = config.refusal_retries;
2591    }
2592
2593    pub fn refusal_retries(&self) -> u32 {
2594        self.refusal_retries
2595    }
2596
2597    pub fn set_api_retries(&mut self, v: u32) {
2598        self.api_retries = v;
2599    }
2600
2601    pub fn telemetry_level(&self) -> crate::runtime::telemetry::TelemetryLevel {
2602        self.telemetry_level
2603    }
2604
2605    pub fn set_telemetry_level(&mut self, level: crate::runtime::telemetry::TelemetryLevel) {
2606        self.telemetry_level = level;
2607        self.sync_observability();
2608    }
2609
2610    /// This session's telemetry, for the subagents it spawns.
2611    pub fn worker_telemetry(&self) -> crate::runtime::telemetry::WorkerTelemetry {
2612        crate::runtime::telemetry::WorkerTelemetry {
2613            level: self.telemetry_level,
2614            writer: self.telemetry_writer.clone(),
2615            parent_session_id: self.session_id.clone(),
2616            parent_runtime_session_id: Some(self.trace_ctx.session_id().as_str().to_string()),
2617            ..Default::default()
2618        }
2619    }
2620
2621    /// Record a worker's requests at its parent's telemetry level, through
2622    /// the parent's session writer. Worker runtimes start `Off`
2623    /// (`from_parts`), so without this every subagent request was missing
2624    /// from `api-log.jsonl`.
2625    ///
2626    /// The writer is shared, not rebuilt: it owns its files from one thread,
2627    /// and a writer per worker would race rotation. The trace context is
2628    /// fresh, though: a worker is its own trace session with its own
2629    /// cache-break snapshots, so its requests never read as breaks in the
2630    /// parent's conversation. A parent that is `Off` leaves the worker `Off`.
2631    pub fn inherit_telemetry(&mut self, parent: &crate::runtime::telemetry::WorkerTelemetry) {
2632        match (&parent.writer, parent.level.enabled()) {
2633            (Some(writer), true) => {
2634                self.telemetry_level = parent.level;
2635                self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2636                    crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2637                ));
2638                // Same writer, tagged: the worker's records carry its agent,
2639                // handle and the parent's session id.
2640                self.telemetry_writer = parent.worker_writer();
2641            }
2642            _ => self.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off),
2643        }
2644    }
2645
2646    /// Reconcile the shared observability writer + trace sink with the
2647    /// telemetry level (Task 11 config rule, documented until Task 12 adds
2648    /// explicit trace config/UI): `basic`/`full` enables BOTH legacy
2649    /// telemetry persistence and metadata-only trace persistence through
2650    /// one bounded session writer; `off` disables both. The trace schema is
2651    /// structurally metadata-only, so this never persists raw content.
2652    fn sync_observability(&mut self) {
2653        if self.telemetry_level.enabled() {
2654            if self.telemetry_writer.is_none() {
2655                let writer = crate::runtime::telemetry::TelemetryWriter::new(
2656                    crate::runtime::telemetry::WriterOptions {
2657                        max_generations: self.telemetry_log_generations,
2658                        ..Default::default()
2659                    },
2660                );
2661                self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2662                    crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2663                ));
2664                self.telemetry_writer = Some(writer);
2665            }
2666        } else {
2667            // Dropping this handle detaches the worker: it drains whatever
2668            // is queued and exits on its own — never a hang. In-flight
2669            // requests holding a cloned handle keep enqueueing harmlessly.
2670            self.telemetry_writer = None;
2671            self.trace_ctx = trace::TraceContext::disabled();
2672        }
2673    }
2674
2675    /// The session trace context handed to every request (cheap clone).
2676    pub fn trace_context(&self) -> trace::TraceContext {
2677        self.trace_ctx.clone()
2678    }
2679
2680    /// Effective trace context for the next outgoing model request:
2681    /// consumes a pending one-shot arm (`/trace next [content]`, Task 12).
2682    ///
2683    /// One-shot semantics (B1): the arm covers exactly one *logical
2684    /// request* — the returned armed context carries a one-shot request
2685    /// gate consumed inside `RequestTracer::begin`, so the first request
2686    /// through it (all retry attempts included) emits records and every
2687    /// subsequent request sharing the same `ApiOptions` (tool-loop
2688    /// continuations) is disabled. Normal Basic/Full session contexts are
2689    /// never gated.
2690    ///
2691    /// When the session sink is disabled the one-shot record rides an
2692    /// ephemeral writer forked from the session context (same session ID,
2693    /// digest key, degradation counters, and §6.6 cache snapshot store —
2694    /// so `/context` sees the armed request's diagnostics). The writer
2695    /// handle is retained in `one_shot_trace_writer` until replaced or
2696    /// drained by the shutdown epilogue, guaranteeing the armed record
2697    /// flushes on session exit. With the session sink already enabled
2698    /// (telemetry Basic/Full) the arm only adds the optional content
2699    /// capture.
2700    pub(crate) fn effective_trace_context(&self) -> trace::TraceContext {
2701        match self.trace_controls.consume() {
2702            None => self.trace_ctx.clone(),
2703            Some(with_content) => {
2704                let base = if self.trace_ctx.enabled() {
2705                    self.trace_ctx.clone()
2706                } else {
2707                    let writer = crate::runtime::telemetry::TelemetryWriter::new(
2708                        crate::runtime::telemetry::WriterOptions::default(),
2709                    );
2710                    // Retain the writer for the exit epilogue (drops any
2711                    // previously retained one-shot writer, whose worker
2712                    // keeps draining in the background).
2713                    *self
2714                        .one_shot_trace_writer
2715                        .lock()
2716                        .expect("one-shot trace writer lock poisoned") = Some(writer.clone());
2717                    self.trace_ctx
2718                        .fork_with_sink(Arc::new(crate::runtime::telemetry::WriterTraceSink::new(
2719                            writer,
2720                        )))
2721                        .with_one_shot_request_gate()
2722                };
2723                if with_content {
2724                    base.with_content_capture(Arc::new(trace::ContentCapture::new(
2725                        self.capture_dir.clone(),
2726                    )))
2727                } else {
2728                    base
2729                }
2730            }
2731        }
2732    }
2733
2734    /// Arm tracing for exactly the next outgoing provider request
2735    /// (`/trace next`; `with_content` adds the one-request redacted
2736    /// content capture for `/trace next content`).
2737    pub fn trace_arm_next(&self, with_content: bool) {
2738        self.trace_controls.arm_next(with_content);
2739    }
2740
2741    /// Metadata-only `/trace status` report: mode, persistence path,
2742    /// counters. Never secrets or content. Also performs the opportunistic
2743    /// expired-capture sweep (B2): status is a trace interaction, so stale
2744    /// content-capture bundles are physically removed here too.
2745    pub fn trace_status(&self) -> trace::TraceStatusReport {
2746        let _ = trace::sweep_expired_captures(&self.capture_dir);
2747        trace::TraceStatusReport {
2748            persistent_enabled: self.telemetry_writer.is_some(),
2749            arm: self.trace_controls.peek(),
2750            trace_path: crate::runtime::telemetry::default_trace_log_path(),
2751            writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2752            degraded_records: self.trace_ctx.degraded_records(),
2753        }
2754    }
2755
2756    /// Structured `/context` report (Task 12): counts, byte lengths, cache
2757    /// change/reuse estimates, and writer counters — never content.
2758    ///
2759    /// `history` is the conversation owned by the calling surface (TUI /
2760    /// headless); when it is not provided the history lines honestly read
2761    /// `unavailable` rather than fabricating zeros. Loaded skills/memories
2762    /// are session-surface state the runtime cannot enumerate, so they are
2763    /// reported `unavailable` with that provenance.
2764    pub fn context_report(&self, history: Option<&[crate::SharedMessage]>) -> trace::ContextReport {
2765        use trace::ReportValue;
2766        let (history_messages, history_bytes) = match history {
2767            Some(msgs) => (
2768                ReportValue::Count(msgs.len() as u64),
2769                ReportValue::Count(
2770                    msgs.iter()
2771                        .map(|m| {
2772                            serde_json::to_vec(&**m)
2773                                .map(|v| v.len() as u64)
2774                                .unwrap_or(0)
2775                        })
2776                        .sum(),
2777                ),
2778            ),
2779            None => (ReportValue::Unavailable, ReportValue::Unavailable),
2780        };
2781        // Non-blocking view of the tool registry: if it is momentarily
2782        // write-locked, report honestly instead of blocking or guessing.
2783        let tool_count = match self.tools.try_read() {
2784            Ok(tools) => ReportValue::Count(tools.iter_tools_sorted().len() as u64),
2785            Err(_) => ReportValue::Unavailable,
2786        };
2787        trace::ContextReport {
2788            model: self.model.clone(),
2789            system_prompt_bytes: ReportValue::Count(
2790                self.system_prompt().map(|s| s.len() as u64).unwrap_or(0),
2791            ),
2792            tool_count,
2793            history_messages,
2794            history_bytes,
2795            loaded_skills: ReportValue::Unavailable,
2796            loaded_memories: ReportValue::Unavailable,
2797            cache: self.trace_ctx.cache_snapshots().last_activity(),
2798            trace_enabled: self.trace_ctx.enabled(),
2799            writer_stats: self.telemetry_writer.as_ref().map(|w| w.stats()),
2800            degraded_records: self.trace_ctx.degraded_records(),
2801        }
2802    }
2803
2804    /// The shared observability writer, when telemetry is enabled.
2805    pub fn telemetry_writer(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
2806        self.telemetry_writer.clone()
2807    }
2808
2809    /// The writer handle for this runtime's requests: stamps the persisted
2810    /// conversation id (`session_id`) on every api-log record, keeping any
2811    /// worker identity the handle already carries.
2812    pub(crate) fn request_telemetry(&self) -> Option<crate::runtime::telemetry::TelemetryWriter> {
2813        self.telemetry_writer
2814            .as_ref()
2815            .map(|w| w.with_session(self.session_id.clone()))
2816    }
2817
2818    /// Bounded shutdown flush of the observability writer: stop intake,
2819    /// drain until `timeout`, return typed stats (`None` when telemetry is
2820    /// off). Sync and bounded — safe from shutdown paths that cannot await;
2821    /// async callers should use [`Self::shutdown_observability_async`].
2822    ///
2823    /// Semantics (Task 11):
2824    /// - telemetry `off` → no writer exists → `None`, a true no-op;
2825    /// - "flushed" means every queued record was appended into OS file
2826    ///   buffers (`write(2)` returned) — deliberately no `fsync`, these are
2827    ///   best-effort diagnostic logs;
2828    /// - on timeout the worker stays detached and keeps draining in the
2829    ///   background; the caller logs metadata-only stats and continues —
2830    ///   trace loss must never fail or abort a clean exit.
2831    pub fn shutdown_observability(
2832        &self,
2833        timeout: std::time::Duration,
2834    ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2835        // Operational retention (Task 12): the exit epilogue also removes
2836        // expired content-capture bundles — bounded, fail-soft, so a stale
2837        // bundle never has to wait for the next trace interaction. Swept at
2838        // the CONSTRUCTION-BOUND root (fix1 I2b), immune to env churn.
2839        let _ = trace::sweep_expired_captures(&self.capture_dir);
2840        // Drain a retained one-shot ephemeral writer first (Task 12 fix:
2841        // an armed `/trace next` record written with telemetry Off must
2842        // survive session exit). Its outcome is returned only when no
2843        // session writer exists — the session writer's stats remain the
2844        // primary signal.
2845        let ephemeral = self
2846            .one_shot_trace_writer
2847            .lock()
2848            .expect("one-shot trace writer lock poisoned")
2849            .take();
2850        let ephemeral_outcome = ephemeral.map(|w| w.shutdown(timeout));
2851        self.telemetry_writer
2852            .as_ref()
2853            .map(|w| w.shutdown(timeout))
2854            .or(ephemeral_outcome)
2855    }
2856
2857    /// Async epilogue helper for every clean process/runtime exit path that
2858    /// owns a `Runtime` (headless chat, TUI teardown,
2859    /// RPC/server graceful shutdown): clones the writer handle and runs the
2860    /// bounded [`Self::shutdown_observability`] on the blocking pool, so an
2861    /// executor thread is never parked. Same `None`/no-fsync/timeout
2862    /// semantics as the sync variant. Idempotent — a second call finds the
2863    /// intake already closed and returns immediately.
2864    ///
2865    /// Note: the writer is shared across `Runtime` clones (subagents). Call
2866    /// this only from the session owner's exit epilogue, after in-flight
2867    /// work has drained — cloned runtimes still serving requests would see
2868    /// their subsequent records counted as drops.
2869    pub async fn shutdown_observability_async(
2870        &self,
2871        timeout: std::time::Duration,
2872    ) -> Option<crate::runtime::telemetry::ShutdownOutcome> {
2873        // Same operational retention sweep as the sync epilogue, off the
2874        // executor (bounded filesystem work, fail-soft on a failed spawn).
2875        // Construction-bound root (fix1 I2b).
2876        let capture_dir = self.capture_dir.clone();
2877        let _ =
2878            tokio::task::spawn_blocking(move || trace::sweep_expired_captures(&capture_dir)).await;
2879        // Same one-shot ephemeral drain as the sync variant.
2880        let ephemeral = self
2881            .one_shot_trace_writer
2882            .lock()
2883            .expect("one-shot trace writer lock poisoned")
2884            .take();
2885        let ephemeral_outcome = match ephemeral {
2886            Some(writer) => Some(writer.shutdown_async(timeout).await),
2887            None => None,
2888        };
2889        match self.telemetry_writer.clone() {
2890            Some(writer) => Some(writer.shutdown_async(timeout).await),
2891            None => ephemeral_outcome,
2892        }
2893    }
2894
2895    /// Test seam: install a custom writer (e.g. temp paths, artificial
2896    /// write delay) as the session observability sink, exactly as
2897    /// `sync_observability` would for a production writer.
2898    #[cfg(any(test, feature = "testing"))]
2899    pub fn install_observability_for_tests(
2900        &mut self,
2901        writer: crate::runtime::telemetry::TelemetryWriter,
2902    ) {
2903        self.trace_ctx = trace::TraceContext::with_sink(Arc::new(
2904            crate::runtime::telemetry::WriterTraceSink::new(writer.clone()),
2905        ));
2906        self.telemetry_writer = Some(writer);
2907    }
2908
2909    pub fn cache_diagnostics(&self) -> bool {
2910        self.cache_diagnostics
2911    }
2912
2913    pub fn set_cache_diagnostics(&mut self, v: bool) {
2914        self.cache_diagnostics = v;
2915    }
2916
2917    /// `request_compression` / `request_compression_level`, as `apply_config`
2918    /// sets them (the process-wide refusal latch included).
2919    pub fn set_request_compression(
2920        &mut self,
2921        mode: agent_core::config::RequestCompressionMode,
2922        level: u32,
2923    ) {
2924        self.request_compression = request_encoding::RequestCompression::from_config(mode, level);
2925    }
2926
2927    /// `(mode, level)` of the gzip policy this runtime's requests carry.
2928    #[cfg(test)]
2929    pub(crate) fn request_compression_for_test(
2930        &self,
2931    ) -> (Option<agent_core::config::RequestCompressionMode>, u32) {
2932        self.request_compression.configured()
2933    }
2934
2935    /// The configured cache TTL (`auto` unless config or a caller fixed it).
2936    /// What a request actually sends is [`Self::resolved_cache_ttl`].
2937    pub fn cache_ttl(&self) -> crate::core::config::CacheTtlSetting {
2938        self.cache_ttl
2939    }
2940
2941    /// The wire TTL the next Anthropic request will carry: the configured
2942    /// setting resolved against the credential kind currently bound
2943    /// (`auto`: 1h on OAuth/subscription, 5m on an API key). Same function
2944    /// the request builder applies, so keep-warm scheduling and status
2945    /// agree with the wire. Depends only on the kind, never on which
2946    /// account: failover between seats of one kind leaves it unchanged.
2947    pub async fn resolved_cache_ttl(&self) -> crate::core::config::CacheTtl {
2948        let auth = self.auth.read().await;
2949        self.cache_ttl.resolve_for_auth_type(&auth.auth_type)
2950    }
2951
2952    /// Thread a parent runtime's TTL atomics onto this (cloned) runtime so
2953    /// that a driver-owned turn shares the session's single downgrade latch
2954    /// instead of firing a redundant 1h→5m notice.
2955    pub fn share_ttl_latches(&mut self, parent: &Runtime) {
2956        self.ttl_downgrade_notified = parent.ttl_downgrade_notified.clone();
2957        self.saw_1h_honored = parent.saw_1h_honored.clone();
2958        self.cache_anchor = parent.cache_anchor.clone();
2959        // Same session, same diagnostics chain: a driver turn continues it,
2960        // and a ping sees the same rejection latch (it never moves the chain).
2961        self.last_msg_id = parent.last_msg_id.clone();
2962        self.cache_diag_rejected = parent.cache_diag_rejected.clone();
2963    }
2964
2965    /// The cache-diagnosis chain for this runtime's requests, `None` when
2966    /// `cache_diagnostics` is off (wire unchanged).
2967    pub(crate) fn cache_diagnosis(&self) -> Option<cache_diagnosis::CacheDiagnosis> {
2968        self.cache_diagnostics.then(|| {
2969            cache_diagnosis::CacheDiagnosis::new(
2970                Arc::clone(&self.last_msg_id),
2971                Arc::clone(&self.cache_diag_rejected),
2972            )
2973        })
2974    }
2975
2976    /// Test seam: set the bound Anthropic credential kind (`oauth` |
2977    /// `api_key` | `none`) so `cache_ttl = auto` resolution can be exercised
2978    /// without a real login.
2979    #[cfg(test)]
2980    pub(crate) async fn set_auth_kind_for_test(&self, kind: &str) {
2981        self.auth.write().await.auth_type = kind.to_string();
2982    }
2983
2984    /// Change the cache TTL strategy mid-session. The next request re-marks
2985    /// with the new TTL; the old prefix expires naturally (single-last
2986    /// strategy never prunes old markers, so no invalidation logic needed).
2987    /// Accepts a fixed [`crate::core::config::CacheTtl`] or a full
2988    /// [`crate::core::config::CacheTtlSetting`] (including `auto`).
2989    pub fn set_cache_ttl(&mut self, ttl: impl Into<crate::core::config::CacheTtlSetting>) {
2990        self.cache_ttl = ttl.into();
2991    }
2992
2993    /// Worker rollover policy from config (`apply_subagent_runtime_policy`).
2994    /// Inert until [`Self::attach_worker_transcripts`] gives it somewhere to
2995    /// keep evicted history.
2996    pub fn set_worker_context_settings(
2997        &mut self,
2998        settings: Option<worker_context::WorkerContextSettings>,
2999    ) {
3000        self.worker_context.settings = settings;
3001    }
3002
3003    /// Where this worker's evicted history goes (its archive directory and
3004    /// handle), plus who to tell. Set per spawn, before the run starts.
3005    pub fn attach_worker_transcripts(&mut self, sink: worker_context::WorkerTranscriptSink) {
3006        self.worker_context.sink = Some(sink);
3007    }
3008
3009    pub fn worker_context(&self) -> &worker_context::WorkerContext {
3010        &self.worker_context
3011    }
3012
3013    pub fn thinking_level(&self) -> &str {
3014        // For Max/Ultra the named_level wins; for legacy budget-only levels
3015        // we fall through to the legacy bucketing.
3016        match self.named_level {
3017            Some(level) => level.as_str(),
3018            None => crate::core::models::thinking_level_for_budget(self.thinking_budget),
3019        }
3020    }
3021
3022    /// Check if the OAuth token is expired and refresh it if needed.
3023    pub async fn refresh_if_needed(&self) -> Result<()> {
3024        self.refresh_if_needed_for_model(&self.model).await
3025    }
3026
3027    /// `/auth reload`: drop the in-memory Anthropic token and re-vend it
3028    /// through the credential broker (re-reads auth.json locally, forcing a
3029    /// refresh if the file still holds the token we had). Returns a
3030    /// token-free status line.
3031    pub async fn reload_auth(&self) -> Result<String> {
3032        AuthMethods::reload_after_rejection(
3033            Arc::clone(&self.auth),
3034            &self.client,
3035            &self.credential_source,
3036            &self.token_cache,
3037            None,
3038        )
3039        .await?;
3040        let expires = self.auth.read().await.token_expires;
3041        let source = if self.credential_source.is_remote() {
3042            "remote broker"
3043        } else {
3044            "auth.json"
3045        };
3046        Ok(match expires {
3047            Some(exp) => {
3048                let mins = exp.saturating_sub(crate::epoch_millis()) / 60_000;
3049                format!("Anthropic credentials reloaded from {source} (valid ~{mins} min)")
3050            }
3051            None => format!("Anthropic credentials reloaded from {source}"),
3052        })
3053    }
3054
3055    /// A cache keep-warm ping's refresh: keeps the bound account or fails,
3056    /// never selects or switches one ([`auth::RefreshPoint::KeepWarm`]).
3057    pub(crate) async fn refresh_for_keepwarm(&self) -> Result<()> {
3058        if !crate::runtime::auth::model_is_anthropic(&self.model) {
3059            return Ok(());
3060        }
3061        AuthMethods::refresh_if_needed(
3062            Arc::clone(&self.auth),
3063            &self.client,
3064            &self.credential_source,
3065            &self.token_cache,
3066            Some(&self.model),
3067            crate::runtime::auth::RefreshPoint::KeepWarm,
3068        )
3069        .await
3070    }
3071
3072    async fn refresh_if_needed_for_model(&self, model: &str) -> Result<()> {
3073        // Non-Anthropic models resolve their own provider auth in the OpenAI
3074        // path (incl. via the broker), so skip the Anthropic pre-fetch. (#158 #7)
3075        if !crate::runtime::auth::model_is_anthropic(model) {
3076            return Ok(());
3077        }
3078        AuthMethods::refresh_if_needed(
3079            Arc::clone(&self.auth),
3080            &self.client,
3081            &self.credential_source,
3082            &self.token_cache,
3083            Some(model),
3084            crate::runtime::auth::RefreshPoint::TurnStart,
3085        )
3086        .await
3087    }
3088
3089    /// Make a simple non-streaming API call for compaction (no tools).
3090    ///
3091    /// Uses a dedicated summarization system prompt (not the user's), omits
3092    /// all tools, and returns the raw text response. Caller supplies the
3093    /// full message array including the serialized conversation.
3094    pub async fn compact_call(&self, messages: Vec<crate::SharedMessage>) -> Result<String> {
3095        // Transport-construction seam: counted BEFORE preflight, auth
3096        // refresh, or any request assembly (CP-12 M3).
3097        self.remote_summarization_attempts
3098            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3099        let model = self.compaction_model();
3100        self.validate_request_preflight_for(
3101            model,
3102            crate::runtime::openai::catalog::CodexRequestRole::Internal,
3103        )
3104        .await?;
3105        self.refresh_if_needed_for_model(model).await?;
3106
3107        use crate::runtime::compaction::COMPACTION_SYSTEM_PROMPT;
3108
3109        ApiMethods::call_api_simple(
3110            &self.auth,
3111            &self.client,
3112            model,
3113            COMPACTION_SYSTEM_PROMPT,
3114            self.thinking_budget,
3115            self.reasoning_level(),
3116            &messages,
3117            self.api_retries,
3118            // Default options preserve this path's historical beta gating
3119            // and endpoint; only the session observability seams are wired
3120            // (Task 11) so compaction requests trace/persist like any other.
3121            // Deliberately the BASE context: an internal compaction request
3122            // must not consume a user's one-shot `/trace next` arm.
3123            &api::ApiOptions {
3124                trace: self.trace_ctx.clone(),
3125                telemetry: self.telemetry_writer.clone(),
3126                // The largest body Synaps sends: the whole transcript.
3127                request_compression: self.request_compression.clone(),
3128                ..api::ApiOptions::default()
3129            },
3130        )
3131        .await
3132    }
3133
3134    /// Run a prompt as a cancellable stream of [`StreamEvent`]s. Convenience wrapper
3135    /// around [`run_stream_with_messages`] for single-turn usage.
3136    pub async fn run_stream(
3137        &self,
3138        prompt: String,
3139        cancel: CancellationToken,
3140    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3141        self.run_stream_with_messages(
3142            vec![std::sync::Arc::new(
3143                json!({"role": "user", "content": prompt}),
3144            )],
3145            cancel,
3146            None,
3147            None,
3148            false,
3149        )
3150        .await
3151    }
3152
3153    /// Session-scoped "Allow all" latch for extension Confirm gates: the
3154    /// extension ids the user picked "Allow all from <ext>" for.
3155    pub fn session_allow_all(&self) -> &std::sync::Arc<crate::tools::SessionConfirmLatch> {
3156        &self.session_allow_all
3157    }
3158
3159    /// Bind a worker to the calling session, not the process-wide host.
3160    pub(crate) fn inherit_session_allow_all(
3161        &mut self,
3162        parent: Option<&std::sync::Arc<crate::tools::SessionConfirmLatch>>,
3163    ) {
3164        if let Some(parent) = parent {
3165            self.session_allow_all = parent.clone();
3166        }
3167    }
3168
3169    /// Run a multi-turn conversation as a cancellable stream of [`StreamEvent`]s.
3170    /// This is the main entry point for chat UIs and agents. Handles tool execution,
3171    /// API retries, and dynamic tool registration (MCP) internally.
3172    pub async fn run_stream_with_messages(
3173        &self,
3174        messages: Vec<crate::SharedMessage>,
3175        cancel: CancellationToken,
3176        steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3177        secret_prompt: Option<crate::tools::SecretPromptHandle>,
3178        auto_approve_confirms: bool,
3179    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3180        self.run_stream_tracked(
3181            messages,
3182            cancel,
3183            steering_rx,
3184            secret_prompt,
3185            auto_approve_confirms,
3186            TurnCompletion::new(),
3187        )
3188        .await
3189    }
3190
3191    /// [`Runtime::run_stream_with_messages`] that also reports, through
3192    /// `completion`, whether the turn reached its normal end (see
3193    /// [`TurnCompletion`]).
3194    pub async fn run_stream_tracked(
3195        &self,
3196        messages: Vec<crate::SharedMessage>,
3197        cancel: CancellationToken,
3198        steering_rx: Option<mpsc::UnboundedReceiver<String>>,
3199        secret_prompt: Option<crate::tools::SecretPromptHandle>,
3200        auto_approve_confirms: bool,
3201        completion: TurnCompletion,
3202    ) -> Pin<Box<dyn Stream<Item = StreamEvent> + Send>> {
3203        // CP-11 fix-2 (A): the caller-facing boundary is BOUNDED. The
3204        // internal producer keeps an unbounded sender for API stability;
3205        // the relay drains it eagerly, enforces the fixed preview-delta
3206        // retention budget, and cancels the turn when the caller stream
3207        // is dropped (releasing provider tasks).
3208        let (tx, internal_rx) = mpsc::unbounded_channel();
3209        let bounded_rx =
3210            crate::runtime::relay::spawn_bounded_stream_relay(internal_rx, cancel.clone());
3211
3212        // One correlation ID per turn: carried by the typed terminal outcome
3213        // (spec §5.2) so every frontend can tie the failure to trace lines.
3214        let turn_correlation_id = agent_core::next_turn_correlation_id();
3215
3216        if self
3217            .continuation
3218            .lock()
3219            .unwrap_or_else(std::sync::PoisonError::into_inner)
3220            .durability_blocked
3221        {
3222            let error = RuntimeError::Session(
3223                "context head save is unresolved; reload the session before further inference"
3224                    .into(),
3225            );
3226            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3227                helpers::turn_error_for(&error, &turn_correlation_id),
3228            )));
3229            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3230            return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3231        }
3232
3233        if let Err(error) = self.validate_request_preflight().await {
3234            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3235                helpers::turn_error_for(&error, &turn_correlation_id),
3236            )));
3237            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3238            return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3239        }
3240
3241        let anthropic_execution_plan = match self.authorized_anthropic_plan().await {
3242            Ok(plan) => plan,
3243            Err(error) => {
3244                let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3245                    helpers::turn_error_for(&error, &turn_correlation_id),
3246                )));
3247                let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3248                return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3249            }
3250        };
3251
3252        // Refresh OAuth token if expired after capability preflight.
3253        if let Err(e) = self.refresh_if_needed().await {
3254            let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3255                helpers::turn_error_for(&e, &turn_correlation_id),
3256            )));
3257            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3258            return Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx));
3259        }
3260
3261        // Clone the Arc, not the whole Runtime — the spawned task shares the
3262        // same AuthState so mid-loop token refreshes are visible immediately.
3263        let auth = Arc::clone(&self.auth);
3264        let client = self.client.clone();
3265        let credential_source = self.credential_source.clone();
3266        let token_cache = self.token_cache.clone();
3267        let model = self.model.clone();
3268        let tools = self.tools.clone();
3269        let system_prompt = self.effective_system_prompt().await;
3270        let thinking_budget = self.thinking_budget;
3271        let reasoning_level = self.reasoning_level();
3272        let max_tool_output = self.max_tool_output;
3273        let bash_timeout = self.bash_timeout;
3274        let bash_max_timeout = self.bash_max_timeout;
3275        let subagent_timeout = self.subagent_timeout;
3276        let api_retries = self.api_retries;
3277        let refusal_retries = self.refusal_retries;
3278        let session_manager = self.session_manager.clone();
3279        // Opt into the 1M-context beta header only when the user explicitly
3280        // requested 1M (via context_window setting). Default 200k matches
3281        // Anthropic's claude-code default and gives smarter inference.
3282        let subagent_registry = self.subagent_registry.clone();
3283        // Extra Arc clone for the reaper hook — session takes ownership of the
3284        // original clone above; this one is captured separately by the spawn closure.
3285        let reaper_registry = Arc::clone(&subagent_registry);
3286        let reaper_orchestration = self.orchestration.clone();
3287        let event_queue = self.event_queue.clone();
3288        let options = api::ApiOptions {
3289            // Placeholder: the stream loop installs the tool context its
3290            // own tools are made from before the first provider round.
3291            tool_context: None,
3292            use_1m_context: self.context_window_override == Some(1_000_000),
3293            cache_ttl: self.cache_ttl,
3294            ttl_downgrade_notified: self.ttl_downgrade_notified.clone(),
3295            saw_1h_honored: self.saw_1h_honored.clone(),
3296            credential_source: self.credential_source.clone(),
3297            token_cache: self.token_cache.clone(),
3298            anthropic_base_url: None,
3299            anthropic_stream_client: self.anthropic_stream_client.clone(),
3300            request_compression: self.request_compression.clone(),
3301            anthropic_execution_plan,
3302            codex_request_role: self.codex_request_role(),
3303            thinking_display: self.wire_thinking_display(),
3304            // Placeholder: the stream loop sets it per round once it has
3305            // applied request-local image retention.
3306            retention_frontier: None,
3307            trace: self.effective_trace_context(),
3308            request_correlation: None,
3309            suppress_stream_deltas: false,
3310            telemetry: self.request_telemetry(),
3311            // Threads the runtime-scoped gate identity into extension-
3312            // provider interior tool loops (Task 16).
3313            tool_session_id: Some(self.host_tool_session.clone()),
3314            // Placeholder: the stream loop installs its RETAINED shared
3315            // session tool set handle and request schema projection before
3316            // the first provider round.
3317            session_tool_set: None,
3318            request_tools_schema: None,
3319            anthropic_tool_split: None,
3320            usage_counters: None,
3321            // The foreground turn's accepted requests are what keep-warm
3322            // schedules from.
3323            cache_anchor: Some(self.cache_anchor.clone()),
3324            keepwarm: None,
3325            // The foreground chain: the only requests that thread
3326            // `diagnostics.previous_message_id`.
3327            cache_diagnosis: self.cache_diagnosis(),
3328            context_rollover: None,
3329            // Placeholder: the stream loop installs one per provider round.
3330            round_signal: None,
3331        };
3332
3333        let session = crate::runtime::stream::StreamSession {
3334            memory_backend: self.memory_backend.clone(),
3335            turn_completion: completion,
3336            context_window: self.context_window(),
3337            continuation: self.continuation.clone(),
3338            worker_context: self.worker_context.clone(),
3339            auth,
3340            client,
3341            credential_source,
3342            token_cache,
3343            options,
3344            api_retries,
3345            refusal_retries,
3346            model,
3347            tools,
3348            system_prompt,
3349            thinking_budget,
3350            reasoning_level,
3351            tx: tx.clone(),
3352            cancel,
3353            steering_rx,
3354            max_tool_output,
3355            tool_spill: self.tool_spill(),
3356            read_dedupe: self.read_dedupe,
3357            tool_keepwarm: self.tool_keepwarm.then(|| tool_keepwarm::ToolKeepWarm::new(
3358                self.trace_ctx.clone(),
3359                tool_keepwarm::interval_override(),
3360            )),
3361            bash_timeout,
3362            bash_max_timeout,
3363            subagent_timeout,
3364            session_manager,
3365            subagent_registry,
3366            event_queue,
3367            secret_prompt,
3368            hook_bus: self.hook_bus.clone(),
3369            session_id: self.session_id.clone(),
3370            cwd: self.cwd.clone(),
3371            env: self.env.clone(),
3372            env_stripped: self.env_stripped.clone(),
3373            env_warned: self.env_warned.clone(),
3374            auto_approve_confirms,
3375            session_allow_all: self.session_allow_all.clone(),
3376            telemetry_level: self.telemetry_level,
3377            orchestration: self.orchestration.clone(),
3378            delegation_parent: self.delegation_parent.clone(),
3379            turn_correlation_id: turn_correlation_id.clone(),
3380            progressive_tool_disclosure: self.progressive_tool_disclosure,
3381            tools_always_loaded: Arc::clone(&self.tools_always_loaded),
3382            deferred_tool_loading: self.deferred_tool_loading,
3383            activation_confirm: self.activation_confirm,
3384            tool_session_id: self.host_tool_session.clone(),
3385            retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
3386            pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
3387            mcp_runtime: self.mcp_runtime.clone(),
3388            mcp_session_scope: self.mcp_session_scope.clone(),
3389            extension_runtime: self.extension_runtime.clone(),
3390            extension_session_scope: self.extension_session_scope.clone(),
3391            turn_budget: self.turn_budget.clone(),
3392            owner_steer_budget: self.owner_steer_budget.clone(),
3393            message_clock: self.message_clock.clone(),
3394        };
3395
3396        tokio::spawn(async move {
3397            if let Err(e) = StreamMethods::run_stream_internal(session, messages).await {
3398                let _ = tx.send(StreamEvent::Session(SessionEvent::Error(
3399                    helpers::turn_error_for(&e, &turn_correlation_id),
3400                )));
3401            }
3402            // Engine-owned housekeeping: reap finished subagent handles before
3403            // signalling Done.  Runs on the tokio thread pool — no public sync
3404            // caller becomes async.  Poison-safe via reap_finished internals.
3405            crate::runtime::subagent::reap_finished(
3406                &reaper_registry,
3407                reaper_orchestration.as_deref(),
3408            );
3409            agent_core::core::memstat::log_turn_memory();
3410            let _ = tx.send(StreamEvent::Session(SessionEvent::Done));
3411        });
3412
3413        Box::pin(tokio_stream::wrappers::ReceiverStream::new(bounded_rx))
3414    }
3415}
3416
3417impl Clone for Runtime {
3418    fn clone(&self) -> Self {
3419        Self {
3420            client: self.client.clone(),
3421            anthropic_stream_client: self.anthropic_stream_client.clone(),
3422            auth: Arc::clone(&self.auth),
3423            model: self.model.clone(),
3424            tools: self.tools.clone(),
3425            system_prompt: self.system_prompt.clone(),
3426            effective_prompt: self.effective_prompt.clone(),
3427            prompt_generation: self.prompt_generation,
3428            prompt_reload_source: self.prompt_reload_source.clone(),
3429            thinking_budget: self.thinking_budget,
3430            named_level: self.named_level,
3431            explicit_reasoning: self.explicit_reasoning,
3432            codex_request_role: self.codex_request_role,
3433            context_window_override: self.context_window_override,
3434            continuation: Arc::clone(&self.continuation),
3435            worker_context: self.worker_context.clone(),
3436            compaction_model: self.compaction_model.clone(),
3437            compaction_mode: self.compaction_mode,
3438            compaction_exclusions: self.compaction_exclusions.clone(),
3439            remote_summarization_attempts: Arc::clone(&self.remote_summarization_attempts),
3440            subagent_registry: self.subagent_registry.clone(),
3441            orchestration: self.orchestration.clone(),
3442            event_queue: self.event_queue.clone(),
3443            max_tool_output: self.max_tool_output,
3444            tool_spill_policy: self.tool_spill_policy,
3445            tool_spill_root: self.tool_spill_root.clone(),
3446            tool_spill_stats: Arc::clone(&self.tool_spill_stats),
3447            read_dedupe: self.read_dedupe,
3448            thinking_display: self.thinking_display,
3449            tool_keepwarm: self.tool_keepwarm,
3450            bash_timeout: self.bash_timeout,
3451            bash_max_timeout: self.bash_max_timeout,
3452            subagent_timeout: self.subagent_timeout,
3453            api_retries: self.api_retries,
3454            refusal_retries: self.refusal_retries,
3455            telemetry_level: self.telemetry_level,
3456            telemetry_log_generations: self.telemetry_log_generations,
3457            // Shared session observability: clones (subagents) enqueue into
3458            // the SAME bounded writer and trace context — one worker and one
3459            // set of session-scoped context IDs per session.
3460            telemetry_writer: self.telemetry_writer.clone(),
3461            trace_ctx: self.trace_ctx.clone(),
3462            trace_controls: Arc::clone(&self.trace_controls),
3463            memory_backend: self.memory_backend.clone(),
3464            memory_bound: self.memory_bound,
3465            one_shot_trace_writer: Arc::clone(&self.one_shot_trace_writer),
3466            capture_dir: self.capture_dir.clone(),
3467            cache_diagnostics: self.cache_diagnostics,
3468            cache_ttl: self.cache_ttl,
3469            request_compression: self.request_compression.clone(),
3470            // Subagents are their own session — fresh latches so a downgrade
3471            // in the subagent's chain surfaces its own (single) notice and
3472            // honored-state isn't inherited from the parent.
3473            ttl_downgrade_notified: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3474            saw_1h_honored: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3475            cache_anchor: Default::default(),
3476            // Subagents start their own request chain — inheriting the parent's
3477            // last msg_id would produce bogus `messages_changed` diagnostics.
3478            last_msg_id: Arc::new(Mutex::new(None)),
3479            cache_diag_rejected: Arc::clone(&self.cache_diag_rejected),
3480            session_manager: self.session_manager.clone(),
3481            hook_bus: self.hook_bus.clone(),
3482            reaper_handle: None, // Cloned runtimes don't own the reaper
3483            reaper_cancel: None, // Cloned runtimes don't own the reaper
3484            credential_source: self.credential_source.clone(),
3485            token_cache: self.token_cache.clone(), // shares the same cache (Arc inside)
3486            trusted_worker_models: self.trusted_worker_models.clone(),
3487            subagent_limits: self.subagent_limits,
3488            progressive_tool_disclosure: self.progressive_tool_disclosure,
3489            tools_always_loaded: Arc::clone(&self.tools_always_loaded),
3490            deferred_tool_loading: self.deferred_tool_loading,
3491            activation_confirm: self.activation_confirm,
3492            delegation_parent: self.delegation_parent.clone(),
3493            session_allow_all: self.session_allow_all.clone(),
3494            mcp_runtime: self.mcp_runtime.clone(),
3495            // Clones SHARE the durable session scope: dropping one clone or
3496            // one stream can never kill a sibling's leases.
3497            mcp_session_scope: self.mcp_session_scope.clone(),
3498            extension_runtime: self.extension_runtime.clone(),
3499            // Same durable shared-scope rule for extension leases.
3500            extension_session_scope: self.extension_session_scope.clone(),
3501            turn_budget: self.turn_budget.clone(),
3502            owner_steer_budget: self.owner_steer_budget.clone(),
3503            message_clock: self.message_clock.clone(),
3504            // Clones share the live tool registry, so they share the SAME
3505            // host tool session (matching existing shared-session behavior);
3506            // independently constructed runtimes mint fresh identities and
3507            // can never share session grants.
3508            host_tool_session: self.host_tool_session.clone(),
3509            tool_catalog_prompt: Arc::clone(&self.tool_catalog_prompt),
3510            retained_tool_set: std::sync::Arc::clone(&self.retained_tool_set),
3511            pending_tool_restore: std::sync::Arc::clone(&self.pending_tool_restore),
3512            // Clones serve the same conversation.
3513            session_id: self.session_id.clone(),
3514            cwd: self.cwd.clone(),
3515            env: self.env.clone(),
3516            env_stripped: self.env_stripped.clone(),
3517            env_warned: self.env_warned.clone(),
3518        }
3519    }
3520}
3521
3522#[cfg(test)]
3523mod tests {
3524    use super::*;
3525
3526    /// Task 16 session identity semantics: clones of one Runtime share the
3527    /// live tool registry, so they share the same host tool session;
3528    /// independently constructed runtimes mint fresh identities and can
3529    /// never share session grants.
3530    #[test]
3531    fn host_tool_session_shared_by_clones_fresh_per_runtime() {
3532        let rt = Runtime::new_headless();
3533        let clone = rt.clone();
3534        assert_eq!(
3535            rt.host_tool_session_id(),
3536            clone.host_tool_session_id(),
3537            "clones share the live registry and therefore the host session"
3538        );
3539
3540        let other = Runtime::new_headless();
3541        assert_ne!(
3542            rt.host_tool_session_id(),
3543            other.host_tool_session_id(),
3544            "independently constructed runtimes must never share a session"
3545        );
3546    }
3547
3548    /// Activations handed to a runtime before its first turn are what it
3549    /// hands on (a second reload, or a park, before any turn loses
3550    /// nothing); clones share them; another runtime does not see them.
3551    #[test]
3552    fn handed_activations_are_carried_on_before_the_first_turn() {
3553        use crate::tools::activation::{CarriedActivation, CarriedProvenance};
3554        let probe = |name: &str| CarriedActivation {
3555            tool_id: format!("builtin:{name}"),
3556            schema_digest: "ab".repeat(32),
3557            provenance: CarriedProvenance::BuiltinRuntime,
3558        };
3559        let rt = Runtime::new_headless();
3560        assert!(rt.carried_tool_activations().is_empty());
3561        rt.restore_tool_activations(vec![probe("subagent_start")]);
3562        rt.restore_tool_activations(Vec::new());
3563        rt.restore_tool_activations(vec![probe("subagent_start"), probe("subagent_status")]);
3564        assert_eq!(
3565            rt.carried_tool_activations(),
3566            vec![probe("subagent_start"), probe("subagent_status")],
3567            "merged without duplicates; an empty hand-off clears nothing"
3568        );
3569        assert_eq!(rt.clone().carried_tool_activations().len(), 2);
3570        assert!(Runtime::new_headless().carried_tool_activations().is_empty());
3571    }
3572
3573    /// Task 11 config rule (documented until Task 12 adds explicit trace
3574    /// config): telemetry `basic`/`full` enables the shared session writer
3575    /// sink — legacy telemetry AND metadata-only trace persistence; `off`
3576    /// disables both (trace context reverts to the no-op sink).
3577    #[test]
3578    fn telemetry_level_gates_shared_observability_sink() {
3579        let mut rt = Runtime::new_headless();
3580        assert!(!rt.trace_context().enabled(), "off by default");
3581        assert!(rt.telemetry_writer().is_none());
3582
3583        rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Basic);
3584        assert!(rt.trace_context().enabled(), "basic enables the trace sink");
3585        assert!(rt.telemetry_writer().is_some());
3586
3587        // Same level again: the writer/session context is not recreated.
3588        let ctx_before = format!("{:?}", rt.trace_context());
3589        rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Full);
3590        assert_eq!(format!("{:?}", rt.trace_context()), ctx_before);
3591
3592        // Clones (subagents) share the same writer sink + session context.
3593        let clone = rt.clone();
3594        assert!(clone.trace_context().enabled());
3595        assert_eq!(format!("{:?}", clone.trace_context()), ctx_before);
3596        assert!(clone.telemetry_writer().is_some());
3597
3598        rt.set_telemetry_level(crate::runtime::telemetry::TelemetryLevel::Off);
3599        assert!(!rt.trace_context().enabled(), "off disables both");
3600        assert!(rt.telemetry_writer().is_none());
3601        assert!(rt
3602            .shutdown_observability(std::time::Duration::ZERO)
3603            .is_none());
3604    }
3605
3606    /// Owner-epilogue contract (Task 11): with telemetry Off the async
3607    /// flush is a `None` no-op and idempotent; with a writer installed, a
3608    /// chat-like epilogue (final record queued, then bounded flush) must
3609    /// persist the record; and a slow writer must return within the budget
3610    /// (TimedOut) instead of blocking exit.
3611    #[tokio::test]
3612    async fn shutdown_observability_async_epilogue_contract() {
3613        // Off → None, twice (idempotent no-op, no writer ever created).
3614        let rt = Runtime::new_headless();
3615        let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3616        assert!(rt.shutdown_observability_async(budget).await.is_none());
3617        assert!(rt.shutdown_observability_async(budget).await.is_none());
3618
3619        // Chat-like shutdown: the final queued record is persisted.
3620        let tmp = tempfile::tempdir().unwrap();
3621        let telemetry_path = tmp.path().join("synaps/api-log.jsonl");
3622        let mut rt = Runtime::new_headless();
3623        let writer = crate::runtime::telemetry::TelemetryWriter::new(
3624            crate::runtime::telemetry::WriterOptions {
3625                telemetry_path: Some(telemetry_path.clone()),
3626                trace_path: Some(tmp.path().join("synaps/request-trace.jsonl")),
3627                ..Default::default()
3628            },
3629        );
3630        rt.install_observability_for_tests(writer.clone());
3631        writer.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord {
3632            ts: 7,
3633            model: "claude-sonnet-4-6".to_string(),
3634            ..Default::default()
3635        });
3636        let outcome = rt
3637            .shutdown_observability_async(budget)
3638            .await
3639            .expect("writer installed");
3640        assert!(outcome.is_flushed());
3641        assert_eq!(outcome.stats().written, 1);
3642        assert_eq!(
3643            std::fs::read_to_string(&telemetry_path)
3644                .unwrap()
3645                .lines()
3646                .count(),
3647            1,
3648            "queued final record must be on disk after the epilogue flush"
3649        );
3650        // Idempotent second call: intake already closed, still Flushed.
3651        assert!(rt
3652            .shutdown_observability_async(budget)
3653            .await
3654            .expect("writer still installed")
3655            .is_flushed());
3656
3657        // Slow writer: the epilogue returns within its bounded budget.
3658        let mut rt = Runtime::new_headless();
3659        let slow = crate::runtime::telemetry::TelemetryWriter::new(
3660            crate::runtime::telemetry::WriterOptions {
3661                telemetry_path: Some(tmp.path().join("slow/api-log.jsonl")),
3662                trace_path: Some(tmp.path().join("slow/request-trace.jsonl")),
3663                write_delay: Some(std::time::Duration::from_millis(300)),
3664                ..Default::default()
3665            },
3666        );
3667        rt.install_observability_for_tests(slow.clone());
3668        for _ in 0..10 {
3669            slow.enqueue_telemetry(crate::runtime::telemetry::TelemetryRecord::default());
3670        }
3671        let start = std::time::Instant::now();
3672        let outcome = rt
3673            .shutdown_observability_async(std::time::Duration::from_millis(200))
3674            .await
3675            .expect("writer installed");
3676        assert!(!outcome.is_flushed(), "10×300ms cannot drain in 200ms");
3677        // Generous ceiling (no timing fragility): well under the 3s a full
3678        // drain would need, proving the deadline was honored.
3679        assert!(
3680            start.elapsed() < std::time::Duration::from_millis(1500),
3681            "bounded epilogue must not block exit: {:?}",
3682            start.elapsed()
3683        );
3684    }
3685
3686    /// Task 12 fix: an armed one-shot ephemeral writer (telemetry Off +
3687    /// `/trace next`) is retained by the runtime and drained by the same
3688    /// shutdown epilogue as the session writer, so the armed record cannot
3689    /// be lost to an exit racing the background worker.
3690    #[tokio::test]
3691    async fn one_shot_ephemeral_writer_is_retained_and_drained_at_shutdown() {
3692        let rt = Runtime::new_headless();
3693        assert!(!rt.trace_context().enabled(), "telemetry off");
3694        rt.trace_arm_next(false);
3695        let armed = rt.effective_trace_context();
3696        assert!(armed.enabled(), "armed ephemeral context traces");
3697        // No session writer, but the epilogue still finds (and drains) the
3698        // retained one-shot writer.
3699        let budget = crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT;
3700        let outcome = rt
3701            .shutdown_observability_async(budget)
3702            .await
3703            .expect("retained one-shot writer must be drained by the epilogue");
3704        assert!(outcome.is_flushed());
3705        // Consumed: a second epilogue call finds nothing.
3706        assert!(rt.shutdown_observability_async(budget).await.is_none());
3707    }
3708
3709    /// Task 12 operational expiry: stale content-capture bundles are
3710    /// physically removed at Runtime startup and by BOTH shutdown
3711    /// epilogues (sync and async), not only on trace interactions.
3712    /// Combined into one serialized test: all three paths resolve the
3713    /// capture dir through `SYNAPS_BASE_DIR`.
3714    #[tokio::test]
3715    #[serial_test::serial(synaps_base_dir)]
3716    async fn startup_and_shutdown_paths_sweep_expired_captures() {
3717        struct BaseDirGuard(Option<String>);
3718        impl Drop for BaseDirGuard {
3719            fn drop(&mut self) {
3720                match self.0.take() {
3721                    Some(old) => std::env::set_var("SYNAPS_BASE_DIR", old),
3722                    None => std::env::remove_var("SYNAPS_BASE_DIR"),
3723                }
3724            }
3725        }
3726        let tmp = tempfile::tempdir().unwrap();
3727        let guard = BaseDirGuard(std::env::var("SYNAPS_BASE_DIR").ok());
3728        std::env::set_var("SYNAPS_BASE_DIR", tmp.path());
3729
3730        let cap_dir = trace::default_capture_dir();
3731        assert!(
3732            cap_dir.starts_with(tmp.path()),
3733            "capture dir must be private to the test"
3734        );
3735        let stale_id = trace::TraceId::new("req-stale").unwrap();
3736        let plant_stale = || {
3737            agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
3738            let stale = trace::controls::ContentCaptureBundle {
3739                schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3740                request_id: stale_id.clone(),
3741                created_unix_ms: 1_000,
3742                expires_unix_ms: 2_000, // long past
3743                redacted: true,
3744                over_budget: false,
3745                body: Some(serde_json::json!({"old": true})),
3746            };
3747            let path = trace::controls::capture_path(&cap_dir, &stale_id);
3748            std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3749            path
3750        };
3751
3752        // 1) Runtime startup sweeps.
3753        let stale_path = plant_stale();
3754        let rt = Runtime::new().await.expect("runtime constructs");
3755        assert!(
3756            !stale_path.exists(),
3757            "Runtime startup must physically remove expired capture bundles"
3758        );
3759
3760        // 2) Sync shutdown epilogue sweeps (telemetry off → None outcome).
3761        let stale_path = plant_stale();
3762        let _ = rt.shutdown_observability(std::time::Duration::ZERO);
3763        assert!(
3764            !stale_path.exists(),
3765            "sync shutdown epilogue must remove expired capture bundles"
3766        );
3767
3768        // 3) Async shutdown epilogue sweeps.
3769        let stale_path = plant_stale();
3770        let _ = rt
3771            .shutdown_observability_async(std::time::Duration::from_millis(100))
3772            .await;
3773        assert!(
3774            !stale_path.exists(),
3775            "async shutdown epilogue must remove expired capture bundles"
3776        );
3777        drop(guard);
3778    }
3779
3780    /// fix1 I2b: the capture root binds at CONSTRUCTION. Ambient
3781    /// SYNAPS_BASE_DIR churn after construction must not redirect the
3782    /// sweep — each runtime keeps sweeping its own root (concurrent
3783    /// roots), so a parallel test flipping env can never race the epilogue.
3784    #[tokio::test]
3785    #[serial_test::serial(synaps_base_dir)]
3786    async fn capture_root_binds_at_construction_and_survives_env_churn() {
3787        let base_a = tempfile::tempdir().unwrap();
3788        let base_b = tempfile::tempdir().unwrap();
3789        let old = std::env::var("SYNAPS_BASE_DIR").ok();
3790
3791        std::env::set_var("SYNAPS_BASE_DIR", base_a.path());
3792        let rt_a = Runtime::new().await.expect("runtime A");
3793        std::env::set_var("SYNAPS_BASE_DIR", base_b.path());
3794        let rt_b = Runtime::new().await.expect("runtime B");
3795
3796        let cap_a = base_a.path().join("trace").join("capture");
3797        let cap_b = base_b.path().join("trace").join("capture");
3798        let plant = |dir: &std::path::Path, name: &str| {
3799            agent_core::core::private_fs::ensure_private_dir(dir).unwrap();
3800            let id = trace::TraceId::new(name).unwrap();
3801            let stale = trace::controls::ContentCaptureBundle {
3802                schema: trace::CONTENT_CAPTURE_SCHEMA.to_string(),
3803                request_id: id.clone(),
3804                created_unix_ms: 1_000,
3805                expires_unix_ms: 2_000,
3806                redacted: true,
3807                over_budget: false,
3808                body: Some(serde_json::json!({"old": true})),
3809            };
3810            let path = trace::controls::capture_path(dir, &id);
3811            std::fs::write(&path, serde_json::to_vec(&stale).unwrap()).unwrap();
3812            path
3813        };
3814
3815        // Point ambient env somewhere else entirely — the bound roots must win.
3816        let decoy = tempfile::tempdir().unwrap();
3817        std::env::set_var("SYNAPS_BASE_DIR", decoy.path());
3818
3819        let stale_a = plant(&cap_a, "req-stale-a");
3820        let stale_b = plant(&cap_b, "req-stale-b");
3821        let _ = rt_a.shutdown_observability(std::time::Duration::ZERO);
3822        assert!(
3823            !stale_a.exists(),
3824            "runtime A must sweep the root bound at ITS construction"
3825        );
3826        assert!(
3827            stale_b.exists(),
3828            "runtime A must never sweep another runtime's root"
3829        );
3830        let _ = rt_b
3831            .shutdown_observability_async(std::time::Duration::from_millis(100))
3832            .await;
3833        assert!(
3834            !stale_b.exists(),
3835            "runtime B must sweep the root bound at ITS construction"
3836        );
3837
3838        match old {
3839            Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
3840            None => std::env::remove_var("SYNAPS_BASE_DIR"),
3841        }
3842    }
3843
3844    #[test]
3845    fn reload_rejects_policy_change_without_partial_apply() {
3846        let dir = tempfile::tempdir().unwrap();
3847        let path = dir.path().join("prompt.yaml");
3848        let manifest = |total: &str, content: &str| {
3849            format!(
3850            "schema: synaps-prompt/1\nkernel: kernel\nmodules:\n  - id: kernel\n    version: v1\n    source: builtin\n    priority: 0\n    selectors: {{}}\n    mutability: mutable_guidance\n    content: {content}\npolicies:\n  delegation:\n    mode: enforced\n    allowed_models: [anthropic/claude-sonnet-4-6]\n    max_concurrent_workers: 1\n    max_total_workers: {total}\n"
3851        )
3852        };
3853        std::fs::write(&path, manifest("1", "before")).unwrap();
3854        let parsed =
3855            agent_core::prompt::PromptManifest::parse(&std::fs::read_to_string(&path).unwrap())
3856                .unwrap();
3857        let model =
3858            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-sonnet-4-6").unwrap();
3859        let context = agent_core::prompt::SelectionContext::new(model.clone(), None).unwrap();
3860        let stack = agent_core::prompt::compile_prompt_stack(
3861            &parsed,
3862            &parsed.registry(path.parent()).unwrap(),
3863            &context,
3864            None,
3865        )
3866        .unwrap();
3867        let catalog = crate::orchestration::OrchestrationRuntime::trusted_catalog(
3868            &model,
3869            parsed.delegation_catalog_candidates(),
3870        )
3871        .unwrap();
3872        let digest = parsed
3873            .delegation_policy(model, &catalog)
3874            .unwrap()
3875            .map(|p| p.digest());
3876        let mut runtime = Runtime::new_headless();
3877        runtime.apply_prompt_stack(stack).unwrap();
3878        runtime.retain_prompt_reload_source(path.clone(), context, None, digest.clone());
3879        let generation = runtime.prompt_generation();
3880        let composed = runtime.effective_prompt().unwrap().composed().to_owned();
3881
3882        std::fs::write(&path, manifest("2", "after")).unwrap();
3883        let error = runtime.reload_prompt().unwrap_err().to_string();
3884        assert!(error.contains("cannot safely change delegation policy"));
3885        assert_eq!(runtime.prompt_generation(), generation);
3886        assert_eq!(runtime.effective_prompt().unwrap().composed(), composed);
3887        assert_eq!(
3888            runtime
3889                .prompt_reload_source
3890                .as_ref()
3891                .unwrap()
3892                .delegation_policy_digest,
3893            digest
3894        );
3895    }
3896
3897    #[test]
3898    fn worker_install_does_not_replay_process_global_favorite_grants() {
3899        let foreground =
3900            crate::orchestration::canonical_foreground_identity("anthropic/claude-fable-5")
3901                .unwrap();
3902        let session = Arc::new(
3903            crate::orchestration::OrchestrationRuntime::baseline(foreground, 4, 8).unwrap(),
3904        );
3905        let favorite = "openai-codex/gpt-5.6-sol";
3906        assert!(
3907            session.preflight(favorite).is_err(),
3908            "precondition: the parent session did not grant this identity"
3909        );
3910
3911        let mut worker = Runtime::new_headless();
3912        worker.apply_config(&crate::config::SynapsConfig {
3913            favorite_models: vec![favorite.to_owned()],
3914            ..Default::default()
3915        });
3916        worker.install_worker_orchestration(Arc::clone(&session));
3917
3918        assert!(
3919            session.preflight(favorite).is_err(),
3920            "constructing a child must not mint/replay a favorite-model grant"
3921        );
3922        assert!(!session.effective_choices().contains(&favorite.to_owned()));
3923    }
3924
3925    /// Tool output virtualisation is off on a bare runtime (embedding and
3926    /// test path), follows the config once applied, and spills a foreground
3927    /// session into `<base>/spill/<conversation id>` so retention can remove
3928    /// it with the session.
3929    #[test]
3930    #[serial_test::serial(synaps_base_dir)]
3931    fn tool_spill_follows_config_and_is_keyed_by_the_conversation() {
3932        let base = crate::test_env::BaseDirGuard::new();
3933        let mut runtime = Runtime::new_headless();
3934        assert!(!runtime.tool_spill_policy().is_enabled());
3935        assert!(!runtime.tool_spill().is_enabled());
3936        runtime.apply_config(&crate::config::SynapsConfig::default());
3937        assert_eq!(
3938            runtime.tool_spill_policy(),
3939            tool_spill::ToolSpillPolicy {
3940                threshold_bytes: 12 * 1024,
3941                head_bytes: 4 * 1024,
3942                tail_bytes: 3 * 1024,
3943            }
3944        );
3945        runtime.set_session_id(Some("20261029-120000-ab12".into()));
3946        assert_eq!(
3947            runtime.tool_spill().dir(),
3948            Some(base.path().join("spill").join("20261029-120000-ab12").as_path())
3949        );
3950        // Clones (the same session) share the counters.
3951        let clone = runtime.clone();
3952        let text = "line\n".repeat(4_000);
3953        let virtualised = clone
3954            .tool_spill()
3955            .virtualise("toolu_1", &tool_spill::SpillTarget::Spill, &text)
3956            .expect("over the threshold");
3957        assert_eq!(runtime.tool_spill_totals().virtualised, 1);
3958        assert_eq!(
3959            runtime.tool_spill_totals().kept_out_bytes(),
3960            (text.len() - virtualised.preview.len()) as u64
3961        );
3962        runtime.apply_config(&agent_core::config::load_config_from_str(
3963            "tool_output_spill_bytes = 0\n",
3964        ));
3965        assert!(!runtime.tool_spill().is_enabled(), "0 turns it off");
3966    }
3967
3968    #[test]
3969    fn read_dedupe_is_off_until_configured_then_follows_config() {
3970        let mut runtime = Runtime::new_headless();
3971        assert!(!runtime.read_dedupe(), "off until a config is applied");
3972        runtime.apply_config(&agent_core::config::load_config_from_str(""));
3973        assert!(runtime.read_dedupe(), "the config default is on");
3974        assert!(runtime.clone().read_dedupe(), "clones keep it");
3975        runtime.apply_config(&agent_core::config::load_config_from_str("read_dedupe = off\n"));
3976        assert!(!runtime.read_dedupe());
3977    }
3978
3979    /// `thinking_display` reaches the foreground's requests; a worker (and
3980    /// an internal request) sends summarized whatever the config says, even
3981    /// one cloned from an omitted foreground.
3982    #[test]
3983    fn thinking_display_is_foreground_only() {
3984        use crate::config::ThinkingDisplay;
3985        use crate::runtime::openai::catalog::CodexRequestRole;
3986        let mut runtime = Runtime::new_headless();
3987        assert_eq!(runtime.wire_thinking_display(), ThinkingDisplay::Summarized);
3988        runtime.apply_config(&agent_core::config::load_config_from_str("thinking_display = omitted\n"));
3989        assert_eq!(runtime.thinking_display(), ThinkingDisplay::Omitted);
3990        assert_eq!(runtime.wire_thinking_display(), ThinkingDisplay::Omitted);
3991        assert_eq!(runtime.clone().wire_thinking_display(), ThinkingDisplay::Omitted);
3992
3993        // What `apply_subagent_runtime_policy` does to every worker runtime.
3994        let mut worker = runtime.clone();
3995        worker.set_codex_request_role(CodexRequestRole::Worker);
3996        assert_eq!(worker.thinking_display(), ThinkingDisplay::Omitted, "config kept");
3997        assert_eq!(worker.wire_thinking_display(), ThinkingDisplay::Summarized, "wire forced");
3998        let mut internal = runtime.clone();
3999        internal.set_codex_request_role(CodexRequestRole::Internal);
4000        assert_eq!(internal.wire_thinking_display(), ThinkingDisplay::Summarized);
4001
4002        runtime.apply_config(&agent_core::config::load_config_from_str(""));
4003        assert_eq!(runtime.wire_thinking_display(), ThinkingDisplay::Summarized, "default");
4004    }
4005
4006    #[test]
4007    fn tool_keepwarm_is_off_until_configured_then_follows_config() {
4008        let mut runtime = Runtime::new_headless();
4009        assert!(!runtime.tool_keepwarm(), "off until a config is applied");
4010        runtime.apply_config(&agent_core::config::load_config_from_str(""));
4011        assert!(runtime.tool_keepwarm(), "the config default is on");
4012        assert!(runtime.clone().tool_keepwarm(), "clones keep it");
4013        runtime.apply_config(&agent_core::config::load_config_from_str(
4014            "cache_keepwarm_tools = off\n",
4015        ));
4016        assert!(!runtime.tool_keepwarm());
4017    }
4018
4019    #[test]
4020    fn host_install_may_apply_explicit_operator_favorites() {
4021        let mut runtime = Runtime::new_headless();
4022        let config = crate::config::SynapsConfig {
4023            favorite_models: vec![
4024                "openai-codex/gpt-5.6-luna".to_owned(),
4025                "anthropic/claude-opus-4-6".to_owned(),
4026                // Legacy favorite spelling must retain its existing compatibility.
4027                "claude/claude-fable-5".to_owned(),
4028                // Malformed persisted values must fail closed without bricking boot.
4029                "not-qualified".to_owned(),
4030            ],
4031            ..Default::default()
4032        };
4033        runtime.apply_config(&config);
4034        let foreground =
4035            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4036        runtime.install_orchestration(std::sync::Arc::new(
4037            crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4038        ));
4039
4040        let orchestration = runtime.orchestration().unwrap();
4041        for trusted in [
4042            "openai-codex/gpt-5.6-sol",
4043            "openai-codex/gpt-5.6-luna",
4044            "anthropic/claude-opus-4-6",
4045            "anthropic/claude-fable-5",
4046        ] {
4047            assert!(
4048                orchestration
4049                    .effective_choices()
4050                    .contains(&trusted.to_owned()),
4051                "missing configured worker choice {trusted}: {:?}",
4052                orchestration.effective_choices()
4053            );
4054        }
4055        let authorized = orchestration
4056            .resolve_and_authorize("sa_cross_provider", Some("anthropic/claude-opus-4-6"))
4057            .expect("an explicitly favorited cross-provider model must be authorized");
4058        assert_eq!(authorized.model.as_str(), "anthropic/claude-opus-4-6");
4059        assert!(!orchestration
4060            .effective_choices()
4061            .contains(&"not-qualified".to_owned()));
4062    }
4063
4064    #[test]
4065    fn configured_worker_choices_survive_manifestless_foreground_changes() {
4066        let mut runtime = Runtime::new_headless();
4067        runtime.apply_config(&crate::config::SynapsConfig {
4068            favorite_models: vec!["anthropic/claude-opus-4-6".to_owned()],
4069            ..Default::default()
4070        });
4071        let foreground =
4072            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4073        runtime.install_orchestration(std::sync::Arc::new(
4074            crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap(),
4075        ));
4076
4077        runtime
4078            .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4079            .unwrap();
4080
4081        let orchestration = runtime.orchestration().unwrap();
4082        assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4083        assert!(orchestration
4084            .effective_choices()
4085            .contains(&"anthropic/claude-opus-4-6".to_owned()));
4086        orchestration
4087            .resolve_and_authorize("sa_after_switch", Some("anthropic/claude-opus-4-6"))
4088            .expect("configured worker trust must survive policy replacement");
4089    }
4090
4091    #[test]
4092    fn configured_subagent_limits_size_the_policy_rebuilt_on_model_change() {
4093        let mut runtime = Runtime::new_headless();
4094        assert_eq!(
4095            runtime.subagent_limits(),
4096            crate::config::SubagentLimits::default(),
4097            "defaults before any config"
4098        );
4099        runtime.apply_config(&crate::config::SynapsConfig {
4100            subagent: crate::config::SubagentLimits {
4101                max_concurrent: 24,
4102                max_total: 96,
4103                max_depth: 2,
4104            },
4105            ..Default::default()
4106        });
4107        let foreground =
4108            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap();
4109        runtime.install_orchestration(std::sync::Arc::new(
4110            crate::orchestration::OrchestrationRuntime::from_limits(
4111                foreground,
4112                runtime.subagent_limits(),
4113            )
4114            .unwrap(),
4115        ));
4116        runtime
4117            .try_set_model("xai-auth/grok-4.5-latest".to_owned())
4118            .unwrap();
4119
4120        let orchestration = runtime.orchestration().unwrap();
4121        assert_eq!(orchestration.foreground_model(), "xai-auth/grok-4.5-latest");
4122        assert_eq!(
4123            orchestration.ultracode_readiness("xai-auth/grok-4.5-latest"),
4124            Ok((24, 96)),
4125            "the replacement policy uses the configured limits, not a hardcoded 8/64"
4126        );
4127        assert_eq!(orchestration.reserve_delegation("a", None), Ok(1));
4128        assert_eq!(orchestration.reserve_delegation("b", Some("a")), Ok(2));
4129        assert_eq!(
4130            orchestration.reserve_delegation("c", Some("b")),
4131            Err(crate::orchestration::DelegationTreeDenied::DepthLimit { max_depth: 2 })
4132        );
4133    }
4134
4135    #[test]
4136    fn set_model_replaces_manifestless_orchestration_foreground_snapshot() {
4137        let mut runtime = Runtime::new_headless();
4138        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4139        runtime.install_orchestration(std::sync::Arc::new(
4140            crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4141        ));
4142
4143        runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4144
4145        let orchestration = runtime.orchestration().unwrap();
4146        assert_eq!(
4147            orchestration.foreground_model(),
4148            "openrouter/deepseek/deepseek-v4-pro"
4149        );
4150        assert!(orchestration
4151            .effective_choices()
4152            .contains(&"openrouter/moonshotai/kimi-k2.7-code".to_owned()));
4153        assert!(orchestration
4154            .resolve_and_authorize("sa_kimi", Some("openrouter/moonshotai/kimi-k2.7-code"))
4155            .is_ok());
4156    }
4157
4158    /// Regression ("model change blocked: trusted worker catalog
4159    /// unavailable"): a daemon-hosted session has no live Muse catalog in
4160    /// its process (the picker fetched it in the TUI process). Switching to
4161    /// a Muse id must not depend on that process-local cache.
4162    #[test]
4163    fn set_model_to_uncached_muse_id_rebuilds_orchestration() {
4164        let _guard = crate::runtime::openai::catalog::muse::test_cache_guard();
4165        crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
4166        let mut runtime = Runtime::new_headless();
4167        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4168        runtime.install_orchestration(std::sync::Arc::new(
4169            crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4170        ));
4171
4172        for model in ["muse/muse-wonder-1.0", "muse/muse-live-only-zz5"] {
4173            runtime
4174                .try_set_model(model.to_owned())
4175                .unwrap_or_else(|e| panic!("{model}: {e}"));
4176            assert_eq!(runtime.model(), model);
4177            assert_eq!(runtime.orchestration().unwrap().foreground_model(), model);
4178        }
4179        // Malformed ids still fail closed.
4180        assert!(runtime.try_set_model("muse/Bad Id".to_owned()).is_err());
4181        assert_eq!(runtime.model(), "muse/muse-live-only-zz5");
4182    }
4183
4184    #[test]
4185    fn set_model_refuses_to_orphan_unreconciled_policy_workers() {
4186        let mut runtime = Runtime::new_headless();
4187        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4188        let orchestration = std::sync::Arc::new(
4189            crate::orchestration::OrchestrationRuntime::baseline(glm, 3, 8).unwrap(),
4190        );
4191        orchestration
4192            .resolve_and_authorize("sa_active", None)
4193            .unwrap();
4194        runtime.install_orchestration(orchestration.clone());
4195        runtime.model = "openrouter/z-ai/glm-5.2".to_owned();
4196
4197        let error = runtime
4198            .try_set_model("openrouter/deepseek/deepseek-v4-pro".to_owned())
4199            .unwrap_err();
4200
4201        assert!(error.contains("collection/reconciliation"));
4202        assert_eq!(runtime.model(), "openrouter/z-ai/glm-5.2");
4203        assert!(std::sync::Arc::ptr_eq(
4204            runtime.orchestration().unwrap(),
4205            &orchestration
4206        ));
4207        assert!(orchestration.is_unreconciled("sa_active"));
4208    }
4209
4210    #[test]
4211    fn set_model_keeps_active_typed_manifest_orchestration_unchanged() {
4212        let dir = tempfile::tempdir().unwrap();
4213        let manifest_path = dir.path().join("prompt.yaml");
4214        let glm = agent_core::prompt::QualifiedModelId::parse("openrouter/z-ai/glm-5.2").unwrap();
4215        let mut runtime = Runtime::new_headless();
4216        let orchestration = std::sync::Arc::new(
4217            crate::orchestration::OrchestrationRuntime::baseline(glm.clone(), 3, 8).unwrap(),
4218        );
4219        runtime.install_orchestration(orchestration.clone());
4220        runtime.retain_prompt_reload_source(
4221            manifest_path,
4222            agent_core::prompt::SelectionContext::new(glm, None).unwrap(),
4223            None,
4224            None,
4225        );
4226
4227        runtime.set_model("openrouter/deepseek/deepseek-v4-pro".to_owned());
4228
4229        assert!(std::sync::Arc::ptr_eq(
4230            runtime.orchestration().unwrap(),
4231            &orchestration
4232        ));
4233        assert_eq!(
4234            runtime.orchestration().unwrap().foreground_model(),
4235            "openrouter/z-ai/glm-5.2"
4236        );
4237    }
4238
4239    #[test]
4240    fn set_model_preserves_qualified_provider_ids_and_bare_claude() {
4241        let cases = [
4242            (
4243                "github-copilot/claude-opus-4.8",
4244                "github-copilot/claude-opus-4.8",
4245            ),
4246            ("anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"),
4247            ("google/gemini-2.5-pro", "google/gemini-2.5-pro"),
4248            ("openai/gpt-5", "openai/gpt-5"),
4249            ("claude-opus-4-8", "claude-opus-4-8"),
4250        ];
4251
4252        for (input, expected) in cases {
4253            let mut runtime = Runtime::new_headless();
4254            runtime.set_model(input.to_owned());
4255            assert_eq!(runtime.model(), expected, "input: {input}");
4256        }
4257    }
4258
4259    #[test]
4260    fn set_model_strips_only_legacy_health_status_decoration() {
4261        let cases = [
4262            ("✅  339ms  groq/llama-3.3-70b", "groq/llama-3.3-70b"),
4263            (
4264                "✅  339ms  github-copilot/claude-opus-4.8",
4265                "github-copilot/claude-opus-4.8",
4266            ),
4267            (
4268                "⚠️  1200ms  anthropic/claude-sonnet-4-6",
4269                "anthropic/claude-sonnet-4-6",
4270            ),
4271        ];
4272
4273        for (input, expected) in cases {
4274            let mut runtime = Runtime::new_headless();
4275            runtime.set_model(input.to_owned());
4276            assert_eq!(runtime.model(), expected, "input: {input}");
4277        }
4278    }
4279
4280    #[tokio::test]
4281    async fn confirm_without_prompt_fails_closed() {
4282        let result = resolve_before_tool_call_result(
4283            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4284            None,
4285            false,
4286            None,
4287        )
4288        .await;
4289
4290        assert!(matches!(
4291            result,
4292            crate::extensions::hooks::events::HookResult::Block { reason }
4293                if reason.contains("requires confirmation") && reason.contains("Run deploy?")
4294        ));
4295    }
4296
4297    #[tokio::test]
4298    async fn modify_result_replaces_tool_input() {
4299        let result = resolve_before_tool_call_decision(
4300            serde_json::json!({"command":"rm -rf /"}),
4301            crate::extensions::hooks::events::HookResult::Modify {
4302                input: serde_json::json!({"command":"echo safe"}),
4303            },
4304            None,
4305            false,
4306            None,
4307        )
4308        .await;
4309
4310        match result {
4311            BeforeToolCallDecision::Continue { input } => {
4312                assert_eq!(input, serde_json::json!({"command":"echo safe"}));
4313            }
4314            BeforeToolCallDecision::Block { reason } => panic!("unexpected block: {reason}"),
4315        }
4316    }
4317
4318    #[tokio::test]
4319    async fn confirm_prompt_yes_continues() {
4320        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4321        let handle = crate::tools::SecretPromptHandle::new(tx);
4322
4323        let task = tokio::spawn(async move {
4324            let request = rx.recv().await.expect("confirm prompt request");
4325            assert_eq!(request.title, "Confirm tool call");
4326            assert!(request.prompt.contains("Run deploy?"));
4327            let _ = request.response_tx.send(Some("yes".to_string()));
4328        });
4329
4330        let result = resolve_before_tool_call_result(
4331            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4332            Some(&handle),
4333            false,
4334            None,
4335        )
4336        .await;
4337
4338        task.await.unwrap();
4339        assert!(matches!(
4340            result,
4341            crate::extensions::hooks::events::HookResult::Continue
4342        ));
4343    }
4344
4345    #[tokio::test]
4346    async fn confirm_prompt_non_yes_blocks() {
4347        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4348        let handle = crate::tools::SecretPromptHandle::new(tx);
4349
4350        let task = tokio::spawn(async move {
4351            let request = rx.recv().await.expect("confirm prompt request");
4352            let _ = request.response_tx.send(Some("no".to_string()));
4353        });
4354
4355        let result = resolve_before_tool_call_result(
4356            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4357            Some(&handle),
4358            false,
4359            None,
4360        )
4361        .await;
4362
4363        task.await.unwrap();
4364        assert!(matches!(
4365            result,
4366            crate::extensions::hooks::events::HookResult::Block { reason }
4367                if reason.contains("confirmation denied")
4368        ));
4369    }
4370
4371    fn ext(ids: &[&str]) -> Vec<String> {
4372        ids.iter().map(|s| s.to_string()).collect()
4373    }
4374
4375    fn key(ids: &[&str]) -> Vec<crate::tools::LatchKey> {
4376        ids.iter().map(|s| (s.to_string(), 0)).collect()
4377    }
4378
4379    fn sourced_confirm(
4380        message: &str,
4381        source: &str,
4382        tool: &str,
4383    ) -> crate::extensions::hooks::events::HookResult {
4384        crate::extensions::hooks::events::HookResult::Confirm {
4385            message: message.into(),
4386            sources: ext(&[source]),
4387            tool: Some(tool.into()),
4388            generations: vec![],
4389            modified_input: None,
4390            modified_by: vec![],
4391        }
4392    }
4393
4394    #[test]
4395    fn session_allow_all_clones_share_but_independent_runtimes_do_not() {
4396        use std::sync::Arc;
4397        let parent = Runtime::new_headless();
4398        let cloned = parent.clone();
4399        let independent = Runtime::new_headless();
4400        assert!(Arc::ptr_eq(
4401            parent.session_allow_all(),
4402            cloned.session_allow_all()
4403        ));
4404        assert!(!Arc::ptr_eq(
4405            parent.session_allow_all(),
4406            independent.session_allow_all()
4407        ));
4408        cloned.session_allow_all().allow(&key(&["jev"]));
4409        assert!(parent.session_allow_all().allows_all(&key(&["jev"])));
4410        assert!(!independent.session_allow_all().allows_all(&key(&["jev"])));
4411    }
4412
4413    #[test]
4414    fn session_allow_all_conversation_boundary_isolates_old_clones() {
4415        use std::sync::Arc;
4416        let mut runtime = Runtime::new_headless();
4417        let old_worker = runtime.clone();
4418        old_worker.session_allow_all().allow(&key(&["jev"]));
4419
4420        runtime.begin_conversation(Some("next".into()));
4421        assert_eq!(runtime.session_id(), Some("next"));
4422        assert!(runtime.session_allow_all().is_empty());
4423        assert!(!Arc::ptr_eq(
4424            runtime.session_allow_all(),
4425            old_worker.session_allow_all()
4426        ));
4427        // #148 I2b: workers still running from the old conversation stop
4428        // auto-approving — the old set is revoked before the swap.
4429        assert!(
4430            old_worker.session_allow_all().is_empty(),
4431            "old latch must be cleared, not just replaced"
4432        );
4433        // An old clone can neither grant the new conversation nor re-arm
4434        // the revoked (orphaned) set.
4435        assert!(!old_worker.session_allow_all().allow(&key(&["jev"])));
4436        assert!(old_worker.session_allow_all().is_empty());
4437        assert!(runtime.session_allow_all().is_empty());
4438
4439        let new_worker = runtime.clone();
4440        new_worker.session_allow_all().allow(&key(&["jev"]));
4441        assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
4442        // Explicit resume of even the same ID starts fresh consent.
4443        runtime.begin_conversation(Some("next".into()));
4444        assert!(runtime.session_allow_all().is_empty());
4445        assert!(!Arc::ptr_eq(
4446            runtime.session_allow_all(),
4447            new_worker.session_allow_all()
4448        ));
4449    }
4450
4451    #[test]
4452    fn conversation_boundary_resets_the_cache_diagnosis_chain() {
4453        let mut runtime = Runtime::new_headless();
4454        runtime.set_cache_diagnostics(true);
4455        let mut driver = runtime.clone();
4456        driver.share_ttl_latches(&runtime);
4457        runtime.cache_diagnosis().unwrap().record("msg_old");
4458        // Compaction successor: same conversation, chain kept.
4459        runtime.set_session_id(Some("successor".into()));
4460        assert_eq!(
4461            runtime
4462                .cache_diagnosis()
4463                .unwrap()
4464                .previous_message_id()
4465                .as_deref(),
4466            Some("msg_old")
4467        );
4468        // `/clear` (and resume) start a new chain, seen by shared clones too.
4469        runtime.begin_conversation(Some("next".into()));
4470        assert_eq!(
4471            runtime.cache_diagnosis().unwrap().previous_message_id(),
4472            None
4473        );
4474        assert_eq!(
4475            driver.cache_diagnosis().unwrap().previous_message_id(),
4476            None
4477        );
4478    }
4479
4480    #[test]
4481    fn session_allow_all_identity_change_preserves_compaction_consent() {
4482        use std::sync::Arc;
4483        let mut runtime = Runtime::new_headless();
4484        let worker = runtime.clone();
4485        worker.session_allow_all().allow(&key(&["jev"]));
4486        runtime.set_session_id(Some("linked-successor".into()));
4487        assert!(Arc::ptr_eq(
4488            runtime.session_allow_all(),
4489            worker.session_allow_all()
4490        ));
4491        assert!(runtime.session_allow_all().allows_all(&key(&["jev"])));
4492    }
4493
4494    /// "Allow all from <ext>": the answer continues THIS call and latches
4495    /// that extension only. A later confirm from the same extension resolves
4496    /// without a prompt; a confirm from another extension still prompts.
4497    #[tokio::test]
4498    async fn confirm_prompt_always_latches_only_the_source_extension() {
4499        let latch = crate::tools::SessionConfirmLatch::new();
4500        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4501        let handle = crate::tools::SecretPromptHandle::new(tx);
4502
4503        let task = tokio::spawn(async move {
4504            let first = rx.recv().await.expect("first confirm prompt");
4505            // The dialog is told who asks and for which tool BEFORE answering.
4506            assert_eq!(
4507                first.confirm_scope,
4508                Some(crate::session::ConfirmScope {
4509                    extensions: vec!["jev".into()],
4510                    tool: Some("bash".into()),
4511                    rewritten_by: vec![],
4512                })
4513            );
4514            assert!(first.prompt.starts_with("Extension: jev\nTool: bash\n"), "{}", first.prompt);
4515            let _ = first
4516                .response_tx
4517                .send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
4518            // The next prompt must be the OTHER extension's confirm (jev's
4519            // second confirm is latched and never prompts).
4520            let other = rx.recv().await.expect("other extension still prompts");
4521            assert_eq!(
4522                other.confirm_scope.as_ref().map(|s| s.extensions.clone()),
4523                Some(vec!["other".to_string()])
4524            );
4525            let _ = other.response_tx.send(None);
4526            assert!(rx.recv().await.is_none(), "no further prompts");
4527        });
4528
4529        let first = resolve_before_tool_call_result(
4530            sourced_confirm("Run deploy?", "jev", "bash"),
4531            Some(&handle),
4532            false,
4533            Some(&latch),
4534        )
4535        .await;
4536        assert!(matches!(
4537            first,
4538            crate::extensions::hooks::events::HookResult::Continue
4539        ));
4540        assert_eq!(latch.extensions(), ext(&["jev"]), "latch keyed by source");
4541
4542        let second = resolve_before_tool_call_result(
4543            sourced_confirm("Run deploy again?", "jev", "write"),
4544            Some(&handle),
4545            false,
4546            Some(&latch),
4547        )
4548        .await;
4549        assert!(matches!(
4550            second,
4551            crate::extensions::hooks::events::HookResult::Continue
4552        ));
4553
4554        let other = resolve_before_tool_call_result(
4555            sourced_confirm("Run ls?", "other", "bash"),
4556            Some(&handle),
4557            false,
4558            Some(&latch),
4559        )
4560        .await;
4561        assert!(
4562            matches!(other, crate::extensions::hooks::events::HookResult::Block { .. }),
4563            "another extension's confirm is not covered by jev's latch"
4564        );
4565        drop(handle);
4566        task.await.unwrap();
4567    }
4568
4569    /// Latched A + unlatched B on one call (a merged Confirm): it prompts,
4570    /// the prompt names both, and "Allow all" then latches both.
4571    #[tokio::test]
4572    async fn latched_plus_unlatched_source_prompts_and_allow_all_latches_both() {
4573        let latch = crate::tools::SessionConfirmLatch::new();
4574        latch.allow(&key(&["a"]));
4575        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4576        let handle = crate::tools::SecretPromptHandle::new(tx);
4577        let task = tokio::spawn(async move {
4578            let req = rx.recv().await.expect("must prompt: b is not latched");
4579            assert_eq!(
4580                req.confirm_scope.as_ref().map(|s| s.extensions.clone()),
4581                Some(vec!["a".to_string(), "b".to_string()])
4582            );
4583            assert!(req.prompt.starts_with("Extension: a, b\n"), "{}", req.prompt);
4584            let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
4585        });
4586        let merged = || crate::extensions::hooks::events::HookResult::Confirm {
4587            message: "[a] x\n\n[b] y".into(),
4588            sources: ext(&["a", "b"]),
4589            generations: vec![],
4590            tool: Some("bash".into()),
4591            modified_input: None,
4592            modified_by: vec![],
4593        };
4594        let first = resolve_before_tool_call_result(merged(), Some(&handle), false, Some(&latch)).await;
4595        task.await.unwrap();
4596        assert!(matches!(first, crate::extensions::hooks::events::HookResult::Continue));
4597        assert!(latch.allows_all(&key(&["a", "b"])));
4598        // Now fully latched: no prompt (handle dropped → would block).
4599        drop(handle);
4600        let second = resolve_before_tool_call_result(merged(), None, false, Some(&latch)).await;
4601        assert!(matches!(second, crate::extensions::hooks::events::HookResult::Continue));
4602    }
4603
4604    /// A call site with no latch never offers "Allow all" (no scope), so the
4605    /// dialog and the session's "armed" notice can't promise it.
4606    #[tokio::test]
4607    async fn no_latch_means_no_allow_all_scope() {
4608        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4609        let handle = crate::tools::SecretPromptHandle::new(tx);
4610        let task = tokio::spawn(async move {
4611            let req = rx.recv().await.unwrap();
4612            assert!(req.confirm_scope.is_none());
4613            let _ = req.response_tx.send(Some("y".into()));
4614        });
4615        let r = resolve_before_tool_call_result(
4616            sourced_confirm("Run?", "jev", "bash"),
4617            Some(&handle),
4618            false,
4619            None,
4620        )
4621        .await;
4622        task.await.unwrap();
4623        assert!(matches!(r, crate::extensions::hooks::events::HookResult::Continue));
4624    }
4625
4626    /// prA N3: a Confirm whose input an earlier extension rewrote shows who
4627    /// rewrote it and a capped preview of the FINAL input, ahead of the
4628    /// confirmer's own message; the scope records `rewritten_by` so the
4629    /// session can mark the transcript; approval runs that final input.
4630    #[tokio::test]
4631    async fn rewritten_input_is_shown_in_the_confirm_prompt() {
4632        let latch = crate::tools::SessionConfirmLatch::new();
4633        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4634        let handle = crate::tools::SecretPromptHandle::new(tx);
4635        let task = tokio::spawn(async move {
4636            let req = rx.recv().await.unwrap();
4637            let body = req.prompt.clone();
4638            let scope = req.confirm_scope.clone().unwrap();
4639            let _ = req.response_tx.send(Some("y".into()));
4640            (body, scope)
4641        });
4642        let result = resolve_before_tool_call_result(
4643            crate::extensions::hooks::events::HookResult::Confirm {
4644                message: "Run this bash command?".into(),
4645                sources: ext(&["jev"]),
4646                generations: vec![],
4647                tool: Some("bash".into()),
4648                modified_input: Some(serde_json::json!({"command": "rm -rf build/"})),
4649                modified_by: ext(&["rewriter"]),
4650            },
4651            Some(&handle),
4652            false,
4653            Some(&latch),
4654        )
4655        .await;
4656        let (body, scope) = task.await.unwrap();
4657        let header_end = body.find("Message from jev:\nRun this bash command?").unwrap();
4658        let header = &body[..header_end];
4659        assert!(header.contains("Input rewritten by: rewriter"), "{body}");
4660        assert!(header.contains("Input that will run:\n  rm -rf build/"), "{body}");
4661        assert_eq!(scope.rewritten_by, ext(&["rewriter"]));
4662        assert!(matches!(
4663            result,
4664            crate::extensions::hooks::events::HookResult::Modify { input }
4665                if input == serde_json::json!({"command": "rm -rf build/"})
4666        ));
4667        // No rewrite → no rewrite header, empty rewritten_by.
4668        let plain = confirm_prompt_body("m", &ext(&["jev"]), Some("bash"), None);
4669        assert!(!plain.contains("rewritten"), "{plain}");
4670    }
4671
4672    /// prA I-R3-1: the preview is redacted before it is rendered.
4673    #[test]
4674    fn final_input_preview_redacts_credentials() {
4675        let secrets = [
4676            "SECRET-BEARER-123",
4677            "SECRET-KEY-456",
4678            "SECRET-FIELD-789",
4679            "QmFzaWNTRUNSRVQ=",
4680            "SECRET-TOK-000",
4681            "SECRET-FLAG-111",
4682            "sk-ant-api03-abcdefghijklmnop",
4683            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl",
4684            "ghp_abcdefghijklmnopqrstuvwxyz0123",
4685        ];
4686        let command = serde_json::json!({
4687            "command": "curl -H 'Authorization: Bearer SECRET-BEARER-123' \
4688                        'https://api.example/v1?api_key=SECRET-KEY-456' \
4689                        -H \"authorization: Basic QmFzaWNTRUNSRVQ=\" \
4690                        && TOKEN=SECRET-TOK-000 deploy --password SECRET-FLAG-111 \
4691                        sk-ant-api03-abcdefghijklmnop \
4692                        eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl \
4693                        ghp_abcdefghijklmnopqrstuvwxyz0123"
4694        });
4695        let json = serde_json::json!({
4696            "url": "https://x/y",
4697            "api_key": "SECRET-FIELD-789",
4698            "headers": {"X-Note": "Bearer SECRET-BEARER-123"},
4699            "args": ["--token=SECRET-TOK-000"],
4700        });
4701        for input in [command, json] {
4702            let p = final_input_preview(&input);
4703            for secret in secrets {
4704                assert!(!p.contains(secret), "{secret} leaked in:\n{p}");
4705            }
4706            assert!(p.contains("[REDACTED]"), "{p}");
4707        }
4708        // Non-secret text survives so the user can still judge the call.
4709        let plain = final_input_preview(&serde_json::json!({"command": "rm -rf build/ && ls -la"}));
4710        assert!(plain.contains("rm -rf build/ && ls -la"), "{plain}");
4711    }
4712
4713    /// prA I-R4-2: every shape from the round-4 table is redacted, by the
4714    /// SHARED scrubber (trace export gets the same rules).
4715    #[test]
4716    fn redact_secret_text_covers_round4_shapes() {
4717        let rows: &[(&str, &str)] = &[
4718            ("curl -H 'x-api-key: XAPIKEY-SECRET-1' https://a", "XAPIKEY-SECRET-1"),
4719            ("FOO_TOKEN=TOK-SECRET-2 run", "TOK-SECRET-2"),
4720            ("MY_SECRET=SEC-SECRET-3 ./x", "SEC-SECRET-3"),
4721            ("DB_PASSWORD=PW-SECRET-4 ./x", "PW-SECRET-4"),
4722            ("OPENAI_API_KEY=APIKEY-SECRET-5 ./x", "APIKEY-SECRET-5"),
4723            ("curl -H 'Authorization: Basic QkFTSUMtU0VDUkVULTY='", "QkFTSUMtU0VDUkVULTY="),
4724            ("aws_secret_access_key=AWSSEC-SECRET-7", "AWSSEC-SECRET-7"),
4725            ("git clone https://alice:USERINFO-SECRET-8@github.com/o/r", "USERINFO-SECRET-8"),
4726            ("psql postgres://app:PGPW-SECRET-9@db:5432/prod", "PGPW-SECRET-9"),
4727            ("DATABASE_URL=postgres://app:DBURL-SECRET-10@db/prod ./run", "DBURL-SECRET-10"),
4728            ("curl -H 'Cookie: sid=COOKIE-SECRET-11; theme=dark' https://a", "COOKIE-SECRET-11"),
4729            ("printf 'Set-Cookie: sid=SETCOOKIE-SECRET-12'", "SETCOOKIE-SECRET-12"),
4730            ("curl -u alice:CURLU-SECRET-13 https://a", "CURLU-SECRET-13"),
4731            ("mysql -u root -pMYSQL-SECRET-14 prod", "MYSQL-SECRET-14"),
4732            ("sshpass -p SSHPASS-SECRET-15 ssh host", "SSHPASS-SECRET-15"),
4733            ("aws configure set aws_secret_access_key AWSCFG-SECRET-16", "AWSCFG-SECRET-16"),
4734            ("aws x --secret-access-key FLAG-SECRET-17 --region eu", "FLAG-SECRET-17"),
4735            ("aws x --access-key FLAG-SECRET-18", "FLAG-SECRET-18"),
4736            ("STRIPE_KEY=rk_live_STRIPE-SECRET-19 node app", "STRIPE-SECRET-19"),
4737            ("OPENAI_KEY=OAIKEY-SECRET-20 node app", "OAIKEY-SECRET-20"),
4738        ];
4739        // prA I-R5-1: rows whose shape is unmistakable are also caught by
4740        // the SHARED high-precision scrub (trace export, memory, archive).
4741        let precise = [
4742            "QkFTSUMtU0VDUkVULTY=",
4743            "USERINFO-SECRET-8",
4744            "PGPW-SECRET-9",
4745            "DBURL-SECRET-10",
4746            "COOKIE-SECRET-11",
4747            "SETCOOKIE-SECRET-12",
4748            "CURLU-SECRET-13",
4749            "MYSQL-SECRET-14",
4750            "SSHPASS-SECRET-15",
4751        ];
4752        for (text, secret) in rows {
4753            // The Confirm prompt path catches every row.
4754            let out = redact_secret_text(text);
4755            assert!(!out.contains(secret), "{secret} leaked: {out}");
4756            assert!(out.contains("[REDACTED]"), "{out}");
4757            let preview = final_input_preview(&serde_json::json!({ "command": text }));
4758            assert!(!preview.contains(secret), "{secret} leaked in preview: {preview}");
4759            if precise.contains(secret) {
4760                let mut v = serde_json::Value::String(text.to_string());
4761                crate::runtime::trace::export::redact_value(&mut v);
4762                assert!(
4763                    !v.as_str().unwrap().contains(secret),
4764                    "shared scrub leaked {secret}: {v}"
4765                );
4766            }
4767        }
4768        // Over-redaction guards: ordinary commands and prose stay intact.
4769        for ok in [
4770            "mkdir -p build/out",
4771            "ssh -p 2222 host",
4772            "ssh -p2222 host",
4773            "docker run -p 8080:80 nginx",
4774            "mysql -h db -P 3306 -p prod",
4775            "curl -u alice https://example.com/path",
4776            "echo the token is valid and the password field is empty",
4777            "monkey=1 make",
4778            "git log --format=%H -- src/",
4779            "https://example.com/a/b?page=2",
4780        ] {
4781            assert_eq!(redact_secret_text(ok), ok, "over-redacted");
4782        }
4783    }
4784
4785    /// prA I-R5-1: the shared scrub (`redact_value`) also runs over the
4786    /// rollover context archive, memory captures and traces, so benign code
4787    /// and prose must pass through it unchanged. Only the Confirm prompt
4788    /// uses the aggressive rules.
4789    #[test]
4790    fn shared_scrub_leaves_benign_code_and_prose_alone() {
4791        let benign = [
4792            "max_tokens: 4096",
4793            "password: String,",
4794            "let token = load()?;",
4795            "the password-reset flow",
4796            "pub authorization: Option<String>,",
4797            "cookie: HashMap<String, String>,",
4798            "MY_KEY=value_in_docs",
4799            "--token-file path/to/file",
4800            "fn refresh_access_token(&self) -> Result<Token>",
4801            "aws_secret_access_key is the field name",
4802        ];
4803        for text in benign {
4804            let mut v = serde_json::Value::String(text.to_string());
4805            crate::runtime::trace::export::redact_value(&mut v);
4806            assert_eq!(v.as_str().unwrap(), text, "shared scrub mangled benign text");
4807            assert_eq!(crate::runtime::trace::export::redact_text(text), text);
4808        }
4809        // Inside objects too (archive / memory records are JSON).
4810        let mut record = serde_json::json!({
4811            "text": "set max_tokens: 4096 and keep the password-reset flow",
4812            "code": ["password: String,", "let token = load()?;"],
4813        });
4814        let before = record.clone();
4815        crate::runtime::trace::export::redact_value(&mut record);
4816        assert_eq!(record, before);
4817    }
4818
4819    /// prA I-R4-1: the confirmer's message is redacted too — it routinely
4820    /// echoes the (credential-injected) command it was shown.
4821    #[test]
4822    fn confirmer_message_echoing_injected_credentials_is_redacted() {
4823        let injected = serde_json::json!({
4824            "command": "curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a"
4825        });
4826        let body = confirm_prompt_body(
4827            "Run: curl -H 'Authorization: Bearer ECHOED-BEARER-X' https://a ?",
4828            &ext(&["jev"]),
4829            Some("bash"),
4830            Some((&ext(&["injector"]), &injected)),
4831        );
4832        assert!(!body.contains("ECHOED-BEARER-X"), "{body}");
4833        assert!(body.contains("Message from jev:\nRun: curl -H 'Authorization: [REDACTED]"), "{body}");
4834    }
4835
4836    /// prA I-R4-3: control, bidi and zero-width characters in the message
4837    /// are stripped before capping, so they can't move the cursor or
4838    /// disguise text in plain-text clients.
4839    #[test]
4840    fn confirm_message_control_and_bidi_chars_are_stripped() {
4841        let hostile = "ok\x1b[3A\x1b[2KExtension: trusted\rTool: none\u{0085}\u{202E}evil\u{200B}x\u{2066}y\tz\nnext";
4842        let body = confirm_prompt_body(hostile, &ext(&["jev"]), Some("bash"), None);
4843        for bad in ['\x1b', '\r', '\u{0085}', '\u{202E}', '\u{200B}', '\u{2066}', '\t'] {
4844            assert!(!body.contains(bad), "{bad:?} survived: {body:?}");
4845        }
4846        assert!(body.contains("Message from jev:\nok[3A[2KExtension: trustedTool: noneevilxy z\nnext"), "{body:?}");
4847        assert!(body.starts_with("Extension: jev\nTool: bash\n"));
4848    }
4849
4850    /// The confirmer's text is labelled, so a fake "Input that will run:"
4851    /// in it lands visibly under "Message from <ext>:", after the host block.
4852    #[test]
4853    fn extension_message_is_labelled_after_the_host_header() {
4854        let fake = "Input that will run:\n  echo harmless";
4855        let body = confirm_prompt_body(fake, &ext(&["jev"]), Some("bash"), None);
4856        let label = body.find("Message from jev:\n").expect(&body);
4857        assert_eq!(body.find("Input that will run:"), Some(label + "Message from jev:\n".len()));
4858        assert!(body.starts_with("Extension: jev\nTool: bash\n"), "{body}");
4859    }
4860
4861    #[test]
4862    fn final_input_preview_is_capped() {
4863        let many = serde_json::json!({"command": (1..=20).map(|i| format!("echo {i}")).collect::<Vec<_>>().join("\n")});
4864        let p = final_input_preview(&many);
4865        assert!(p.lines().count() <= MAX_REWRITE_PREVIEW_LINES + 1, "{p}");
4866        assert!(p.ends_with("…[preview truncated]"), "{p}");
4867        let wide = serde_json::json!({"path": "x".repeat(2000)});
4868        let p = final_input_preview(&wide);
4869        assert!(p.chars().count() <= MAX_REWRITE_PREVIEW_CHARS + 30, "{}", p.len());
4870        let obj = serde_json::json!({"path": "/tmp/a", "content": "hi"});
4871        assert!(final_input_preview(&obj).contains("\"path\": \"/tmp/a\""));
4872    }
4873
4874    /// A long extension message is capped so the host header, buttons and
4875    /// scope line stay on screen.
4876    #[test]
4877    fn long_confirm_message_is_capped() {
4878        let long = "x\n".repeat(500);
4879        let body = confirm_prompt_body(&long, &ext(&["jev"]), Some("bash"), None);
4880        assert!(body.starts_with("Extension: jev\nTool: bash\n"));
4881        assert!(body.contains("…[message truncated by host]"));
4882        assert!(body.lines().count() <= MAX_CONFIRM_MESSAGE_LINES + 8, "{}", body.lines().count());
4883        let wide = "y".repeat(10_000);
4884        assert!(confirm_prompt_body(&wide, &ext(&["jev"]), None, None).chars().count() < 1400);
4885    }
4886
4887    /// An unattributed confirm (no host-stamped source) offers no scope and
4888    /// an "always" answer approves only this call; nothing is latched.
4889    #[tokio::test]
4890    async fn unattributed_confirm_always_answer_does_not_latch() {
4891        let latch = crate::tools::SessionConfirmLatch::new();
4892        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4893        let handle = crate::tools::SecretPromptHandle::new(tx);
4894        let task = tokio::spawn(async move {
4895            let req = rx.recv().await.expect("prompt");
4896            assert!(req.confirm_scope.is_none());
4897            let _ = req.response_tx.send(Some(CONFIRM_ANSWER_ALLOW_ALL.to_string()));
4898        });
4899        let result = resolve_before_tool_call_result(
4900            crate::extensions::hooks::events::HookResult::confirm("Run deploy?"),
4901            Some(&handle),
4902            false,
4903            Some(&latch),
4904        )
4905        .await;
4906        task.await.unwrap();
4907        assert!(matches!(
4908            result,
4909            crate::extensions::hooks::events::HookResult::Continue
4910        ));
4911        assert!(latch.is_empty());
4912    }
4913
4914    /// The latch only short-circuits Confirm; Block from an extension is
4915    /// still a Block, and a headless path with the source latched continues
4916    /// instead of failing closed (an unlatched source still fails closed).
4917    #[tokio::test]
4918    async fn session_allow_all_does_not_override_block() {
4919        let latch = crate::tools::SessionConfirmLatch::new();
4920        latch.allow(&key(&["jev"]));
4921        let blocked = resolve_before_tool_call_result(
4922            crate::extensions::hooks::events::HookResult::Block {
4923                reason: "nope".into(),
4924            },
4925            None,
4926            false,
4927            Some(&latch),
4928        )
4929        .await;
4930        assert!(matches!(
4931            blocked,
4932            crate::extensions::hooks::events::HookResult::Block { reason } if reason == "nope"
4933        ));
4934        let headless = resolve_before_tool_call_result(
4935            sourced_confirm("Run deploy?", "jev", "bash"),
4936            None,
4937            false,
4938            Some(&latch),
4939        )
4940        .await;
4941        assert!(matches!(
4942            headless,
4943            crate::extensions::hooks::events::HookResult::Continue
4944        ));
4945        let other = resolve_before_tool_call_result(
4946            sourced_confirm("Run deploy?", "other", "bash"),
4947            None,
4948            false,
4949            Some(&latch),
4950        )
4951        .await;
4952        assert!(matches!(
4953            other,
4954            crate::extensions::hooks::events::HookResult::Block { .. }
4955        ));
4956    }
4957
4958    #[test]
4959    fn test_max_tokens_for_model() {
4960        // Opus models should return 128000
4961        assert_eq!(
4962            HelperMethods::max_tokens_for_model("claude-opus-4-6"),
4963            128000
4964        );
4965        assert_eq!(
4966            HelperMethods::max_tokens_for_model("opus-something"),
4967            128000
4968        );
4969
4970        // Non-opus models should return 64000
4971        assert_eq!(
4972            HelperMethods::max_tokens_for_model("claude-sonnet-4-20250514"),
4973            64000
4974        );
4975        assert_eq!(HelperMethods::max_tokens_for_model("haiku"), 64000);
4976        assert_eq!(HelperMethods::max_tokens_for_model("claude-3-haiku"), 64000);
4977        assert_eq!(
4978            HelperMethods::max_tokens_for_model("some-other-model"),
4979            64000
4980        );
4981
4982        // Edge cases
4983        assert_eq!(HelperMethods::max_tokens_for_model(""), 64000);
4984        assert_eq!(HelperMethods::max_tokens_for_model("OPUS"), 64000); // Case sensitive - uppercase doesn't match
4985        assert_eq!(
4986            HelperMethods::max_tokens_for_model("model-opus-end"),
4987            128000
4988        ); // Contains "opus" anywhere
4989    }
4990
4991    #[test]
4992    fn test_truncate_tool_result() {
4993        let default_max = 30000;
4994
4995        // Short string should remain unchanged
4996        let short = "This is a short string.";
4997        assert_eq!(
4998            HelperMethods::truncate_tool_result(short, default_max),
4999            short
5000        );
5001
5002        // Exactly max should remain unchanged
5003        let exact = "x".repeat(30000);
5004        assert_eq!(
5005            HelperMethods::truncate_tool_result(&exact, default_max),
5006            exact
5007        );
5008
5009        // String longer than max should be truncated with notice
5010        let too_long = "x".repeat(30001);
5011        let truncated = HelperMethods::truncate_tool_result(&too_long, default_max);
5012
5013        // Should start with the truncated content
5014        assert!(truncated.starts_with(&"x".repeat(30000)));
5015
5016        // Should contain truncation notice with total char count
5017        assert!(truncated.contains("[truncated — 30001 total bytes, showing first 30000]"));
5018
5019        // Should be longer than max (due to notice)
5020        assert!(truncated.len() > 30000);
5021
5022        // Test with a much longer string
5023        let very_long = "a".repeat(50000);
5024        let truncated_very_long = HelperMethods::truncate_tool_result(&very_long, default_max);
5025        assert!(
5026            truncated_very_long.contains("[truncated — 50000 total bytes, showing first 30000]")
5027        );
5028        assert!(truncated_very_long.starts_with(&"a".repeat(30000)));
5029
5030        // Test with custom limit
5031        let custom_truncated = HelperMethods::truncate_tool_result(&very_long, 100);
5032        assert!(custom_truncated.starts_with(&"a".repeat(100)));
5033        assert!(custom_truncated.contains("[truncated — 50000 total bytes, showing first 100]"));
5034    }
5035
5036    /// T2 red→green: `truncate_tool_result` must enforce a BYTE budget on
5037    /// multibyte input (legacy code used `chars().take(n)` — a char budget —
5038    /// so 100 two-byte chars under a 50-byte budget kept 100 bytes).
5039    #[test]
5040    fn truncate_tool_result_enforces_byte_budget() {
5041        let multibyte = "é".repeat(100); // 200 bytes, 100 chars
5042        let out = HelperMethods::truncate_tool_result(&multibyte, 50);
5043        let body = out.split("\n\n[truncated").next().unwrap();
5044        assert!(
5045            body.len() <= 50,
5046            "retained body must fit the byte budget (got {} bytes)",
5047            body.len()
5048        );
5049        assert!(std::str::from_utf8(body.as_bytes()).is_ok());
5050        assert!(
5051            out.contains("total bytes"),
5052            "marker must report byte counts, got: {out}"
5053        );
5054    }
5055
5056    #[test]
5057    fn test_thinking_level_ranges() {
5058        use crate::core::models::thinking_level_for_budget;
5059
5060        // Sentinel 0 = "adaptive" (S172 — model decides)
5061        assert_eq!(thinking_level_for_budget(0), "adaptive");
5062
5063        // Low range: 1..=2048
5064        assert_eq!(thinking_level_for_budget(1), "low");
5065        assert_eq!(thinking_level_for_budget(1024), "low");
5066        assert_eq!(thinking_level_for_budget(2048), "low");
5067
5068        // Medium range: 2049..=4096
5069        assert_eq!(thinking_level_for_budget(2049), "medium");
5070        assert_eq!(thinking_level_for_budget(3000), "medium");
5071        assert_eq!(thinking_level_for_budget(4096), "medium");
5072
5073        // High range: 4097..=16384
5074        assert_eq!(thinking_level_for_budget(4097), "high");
5075        assert_eq!(thinking_level_for_budget(8192), "high");
5076        assert_eq!(thinking_level_for_budget(16384), "high");
5077
5078        // XHigh range: _ (everything else)
5079        assert_eq!(thinking_level_for_budget(16385), "xhigh");
5080        assert_eq!(thinking_level_for_budget(32768), "xhigh");
5081        assert_eq!(thinking_level_for_budget(100000), "xhigh");
5082    }
5083}
5084
5085#[cfg(test)]
5086mod effective_prompt_tests {
5087    use super::*;
5088
5089    // ── progressive-disclosure tool catalog in the system prompt ────────────
5090
5091    struct LateTool;
5092    #[async_trait::async_trait]
5093    impl crate::Tool for LateTool {
5094        fn name(&self) -> &str {
5095            "late_tool"
5096        }
5097        fn description(&self) -> &str {
5098            "Registered after the catalog froze."
5099        }
5100        fn parameters(&self) -> Value {
5101            json!({"type": "object", "properties": {}})
5102        }
5103        async fn execute(&self, _p: Value, _c: crate::ToolContext) -> crate::Result<String> {
5104            Ok(String::new())
5105        }
5106    }
5107
5108    #[tokio::test]
5109    async fn tool_catalog_lists_activatable_tools_outside_the_core() {
5110        let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5111        assert!(
5112            runtime.frozen_tool_catalog().await.is_none(),
5113            "flag off: no catalog"
5114        );
5115        runtime.progressive_tool_disclosure = true;
5116        let catalog = runtime.frozen_tool_catalog().await.expect("catalog");
5117        assert!(catalog.starts_with("## Activatable tools\n"));
5118        assert!(
5119            catalog.contains("not instructions"),
5120            "third-party text is framed as data"
5121        );
5122        assert!(catalog.contains("- builtin:subagent_start"), "{catalog}");
5123        for core in [
5124            "builtin:bash",
5125            "builtin:read",
5126            "builtin:activate_tools",
5127            "builtin:search_tools",
5128        ] {
5129            assert!(
5130                !catalog.contains(&format!("- {core}")),
5131                "core {core} listed"
5132            );
5133        }
5134        let prompt = runtime.effective_system_prompt().await.unwrap();
5135        assert!(prompt.starts_with("BASE."), "base stays first");
5136        assert!(prompt.ends_with(&catalog), "catalog appended last");
5137    }
5138
5139    /// `tools.always_loaded` flows config → runtime → clones (the stream
5140    /// session and subagent forks copy it), and a listed tool is core, so the
5141    /// activatable-tools catalog stops offering it.
5142    #[tokio::test]
5143    async fn tools_always_loaded_flows_from_config_into_the_session_core() {
5144        let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5145        let config = agent_core::config::load_config_from_str(
5146            "progressive_tool_disclosure = true\ntools.always_loaded = builtin:subagent_start\n",
5147        );
5148        runtime.apply_config_keep_tools(&config);
5149        assert!(runtime.progressive_tool_disclosure);
5150        let listed = crate::tools::catalog::ToolId::builtin("subagent_start");
5151        assert_eq!(&*runtime.tools_always_loaded, std::slice::from_ref(&listed));
5152        assert_eq!(&*runtime.clone().tools_always_loaded, std::slice::from_ref(&listed));
5153        let catalog = runtime.frozen_tool_catalog().await.expect("catalog");
5154        assert!(!catalog.contains("- builtin:subagent_start"), "{catalog}");
5155        assert!(catalog.contains("- builtin:subagent_"), "other tools still listed");
5156    }
5157
5158    #[tokio::test]
5159    async fn tool_catalog_is_frozen_per_session() {
5160        let mut runtime = headless("anthropic/claude-sonnet-5-5", "BASE.");
5161        runtime.progressive_tool_disclosure = true;
5162        let first = runtime.effective_system_prompt().await.unwrap();
5163        runtime.tools.write().await.register(Arc::new(LateTool));
5164        let clone = runtime.clone();
5165        assert_eq!(runtime.effective_system_prompt().await.unwrap(), first);
5166        assert_eq!(clone.effective_system_prompt().await.unwrap(), first);
5167        assert!(
5168            !first.contains("late_tool"),
5169            "late registration does not rewrite the prefix"
5170        );
5171    }
5172
5173    #[tokio::test]
5174    async fn tool_catalog_absent_when_activation_is_denied_or_impossible() {
5175        let mut denied = headless("anthropic/claude-sonnet-5-5", "BASE.");
5176        denied.progressive_tool_disclosure = true;
5177        denied.set_activation_confirm(agent_core::config::ActivationConfirm::Deny);
5178        assert!(denied.frozen_tool_catalog().await.is_none());
5179        assert_eq!(
5180            denied.effective_system_prompt().await.as_deref(),
5181            Some("BASE.")
5182        );
5183
5184        let mut worker = headless("anthropic/claude-sonnet-5-5", "BASE.");
5185        worker.progressive_tool_disclosure = true;
5186        worker.set_tools(crate::ToolRegistry::without_subagent());
5187        assert!(
5188            worker.frozen_tool_catalog().await.is_none(),
5189            "no activate_tools, no catalog"
5190        );
5191    }
5192
5193    fn headless(model: &str, base: &str) -> Runtime {
5194        let mut runtime = Runtime::new_headless();
5195        runtime.set_model(model.to_string());
5196        runtime.set_system_prompt(base.to_string());
5197        runtime
5198    }
5199
5200    #[tokio::test]
5201    async fn anthropic_foreground_ultracode_composes_standing_doctrine_once() {
5202        let mut runtime = headless("anthropic/claude-fable-5", "BASE.");
5203        runtime.named_level = Some(agent_core::reasoning::ReasoningLevel::UltraCode);
5204        let prompt = runtime.effective_system_prompt().await.expect("doctrine");
5205        assert_eq!(prompt.matches("<anthropic-ultracode-workflow>").count(), 1);
5206        assert!(prompt.contains("subagent_start"));
5207        assert!(prompt.contains("subagent_resume"));
5208    }
5209
5210    #[tokio::test]
5211    async fn codex_foreground_with_subagent_tools_composes_doctrine() {
5212        let runtime = headless("openai-codex/gpt-5.6-sol", "BASE.");
5213        let prompt = runtime
5214            .effective_system_prompt()
5215            .await
5216            .expect("prompt must compose");
5217        assert!(prompt.starts_with("BASE."), "base must lead");
5218        assert!(prompt.contains("## Subagent supervision"));
5219        assert!(prompt.contains("NEVER end your turn"));
5220    }
5221
5222    #[tokio::test]
5223    async fn worker_runtime_without_subagent_tools_stays_clean() {
5224        let mut runtime = headless("openai-codex/gpt-5.6-sol", "WORKER.");
5225        runtime.set_tools(ToolRegistry::without_subagent());
5226        assert_eq!(
5227            runtime.effective_system_prompt().await.as_deref(),
5228            Some("WORKER."),
5229            "workers have no subagent tools; doctrine would be unactionable noise"
5230        );
5231    }
5232
5233    #[tokio::test]
5234    async fn non_codex_foreground_stays_clean() {
5235        let runtime = headless("xai-auth/grok-4.5-latest", "BASE.");
5236        assert_eq!(
5237            runtime.effective_system_prompt().await.as_deref(),
5238            Some("BASE.")
5239        );
5240    }
5241
5242    #[tokio::test]
5243    async fn manifest_prompt_is_returned_verbatim() {
5244        let mut runtime = headless("openai-codex/gpt-5.6-sol", "IGNORED.");
5245        let module = agent_core::prompt::PromptModule::new(
5246            agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5247            "1.0.0",
5248            agent_core::prompt::PromptModuleSource::User,
5249            0,
5250            agent_core::prompt::PromptSelectors::default(),
5251            agent_core::prompt::ModuleMutability::MutableGuidance,
5252            "KERNEL.",
5253        )
5254        .unwrap();
5255        let context = agent_core::prompt::SelectionContext::new(
5256            agent_core::prompt::QualifiedModelId::parse("openai-codex/gpt-5.6-sol").unwrap(),
5257            None,
5258        )
5259        .unwrap();
5260        let stack = agent_core::prompt::PromptStack::new(vec![module], context).unwrap();
5261        runtime.apply_prompt_stack(stack).unwrap();
5262        assert_eq!(
5263            runtime.effective_system_prompt().await.as_deref(),
5264            Some("KERNEL."),
5265            "manifest authors own the full stack; no builtin injection on top"
5266        );
5267    }
5268
5269    #[tokio::test]
5270    async fn unresolvable_model_identity_fails_closed_to_base() {
5271        let runtime = headless("definitely-not-a-model", "BASE.");
5272        assert_eq!(
5273            runtime.effective_system_prompt().await.as_deref(),
5274            Some("BASE.")
5275        );
5276    }
5277}
5278
5279#[cfg(test)]
5280mod set_reasoning_level_checked_tests {
5281    use super::*;
5282    use agent_core::reasoning::ReasoningLevel;
5283
5284    fn codex_runtime(model_id: &str) -> Runtime {
5285        let mut rt = Runtime::new_headless();
5286        rt.set_model(format!("openai-codex/{model_id}"));
5287        rt.set_reasoning_level(ReasoningLevel::Low);
5288        rt
5289    }
5290
5291    fn provisioned_fable_runtime() -> Runtime {
5292        let mut rt = Runtime::new_headless();
5293        rt.set_model("anthropic/claude-fable-5".into());
5294        rt.set_reasoning_level(ReasoningLevel::Low);
5295        let foreground =
5296            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap();
5297        let orchestration =
5298            crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8).unwrap();
5299        rt.install_orchestration(Arc::new(orchestration));
5300        rt
5301    }
5302
5303    fn install_typed_manifest(rt: &mut Runtime) {
5304        let module = agent_core::prompt::PromptModule::new(
5305            agent_core::prompt::PromptModuleId::parse("kernel.test").unwrap(),
5306            "1.0.0",
5307            agent_core::prompt::PromptModuleSource::User,
5308            0,
5309            agent_core::prompt::PromptSelectors::default(),
5310            agent_core::prompt::ModuleMutability::MutableGuidance,
5311            "KERNEL.",
5312        )
5313        .unwrap();
5314        let context = agent_core::prompt::SelectionContext::new(
5315            agent_core::prompt::QualifiedModelId::parse("anthropic/claude-fable-5").unwrap(),
5316            None,
5317        )
5318        .unwrap();
5319        rt.apply_prompt_stack(agent_core::prompt::PromptStack::new(vec![module], context).unwrap())
5320            .unwrap();
5321    }
5322
5323    #[test]
5324    fn provisioned_fable_accepts_max_and_ultracode() {
5325        for level in [ReasoningLevel::Max, ReasoningLevel::UltraCode] {
5326            let mut rt = provisioned_fable_runtime();
5327            rt.set_reasoning_level_checked(level).unwrap();
5328            assert_eq!(rt.reasoning_level(), level);
5329        }
5330    }
5331
5332    #[test]
5333    fn fable_ultracode_prerequisites_fail_without_mutation() {
5334        let mut missing_orchestration = Runtime::new_headless();
5335        missing_orchestration.set_model("anthropic/claude-fable-5".into());
5336        missing_orchestration.set_reasoning_level(ReasoningLevel::Low);
5337        assert!(missing_orchestration
5338            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5339            .is_err());
5340        assert_eq!(missing_orchestration.reasoning_level(), ReasoningLevel::Low);
5341
5342        let mut missing_tools = provisioned_fable_runtime();
5343        missing_tools.set_tools(ToolRegistry::without_subagent());
5344        assert!(missing_tools
5345            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5346            .is_err());
5347        assert_eq!(missing_tools.reasoning_level(), ReasoningLevel::Low);
5348
5349        let mut worker = provisioned_fable_runtime();
5350        worker.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Worker);
5351        assert!(worker
5352            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5353            .is_err());
5354        assert_eq!(worker.reasoning_level(), ReasoningLevel::Low);
5355
5356        let mut internal = provisioned_fable_runtime();
5357        internal.set_codex_request_role(crate::runtime::openai::catalog::ExecutionRole::Internal);
5358        assert!(internal
5359            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5360            .is_err());
5361        assert_eq!(internal.reasoning_level(), ReasoningLevel::Low);
5362
5363        let mut manifest = provisioned_fable_runtime();
5364        install_typed_manifest(&mut manifest);
5365        assert!(manifest
5366            .set_reasoning_level_checked(ReasoningLevel::UltraCode)
5367            .is_err());
5368        assert_eq!(manifest.reasoning_level(), ReasoningLevel::Low);
5369    }
5370
5371    #[test]
5372    fn checked_accepts_client_omission_modes_for_codex() {
5373        for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
5374            let mut rt = codex_runtime("gpt-5.6-sol");
5375            rt.set_reasoning_level_checked(level).unwrap();
5376            assert_eq!(rt.reasoning_level(), level);
5377        }
5378    }
5379
5380    /// luna does not support Ultra; checked must reject and leave level unchanged.
5381    #[test]
5382    fn checked_rejects_ultra_for_luna_no_mutation() {
5383        let mut rt = codex_runtime("gpt-5.6-luna");
5384        let before = rt.reasoning_level();
5385        let err = rt
5386            .set_reasoning_level_checked(ReasoningLevel::Ultra)
5387            .unwrap_err();
5388        assert!(
5389            err.contains("ultra"),
5390            "error must name the rejected level; got: {err}"
5391        );
5392        assert_eq!(
5393            rt.reasoning_level(),
5394            before,
5395            "runtime must not be mutated when validation fails"
5396        );
5397    }
5398
5399    /// gpt-5.5 does not support Max; checked must reject and not mutate.
5400    #[test]
5401    fn checked_rejects_max_for_gpt55_no_mutation() {
5402        let mut rt = codex_runtime("gpt-5.5");
5403        rt.set_reasoning_level(ReasoningLevel::Medium);
5404        let err = rt
5405            .set_reasoning_level_checked(ReasoningLevel::Max)
5406            .unwrap_err();
5407        assert!(
5408            err.contains("max"),
5409            "error must name the rejected level; got: {err}"
5410        );
5411        assert_eq!(rt.reasoning_level(), ReasoningLevel::Medium);
5412    }
5413
5414    /// sol supports Ultra; checked must accept and mutate.
5415    #[test]
5416    fn checked_accepts_ultra_for_sol_and_mutates() {
5417        let mut rt = codex_runtime("gpt-5.6-sol");
5418        rt.set_reasoning_level_checked(ReasoningLevel::Ultra)
5419            .expect("sol must accept ultra");
5420        assert_eq!(rt.reasoning_level(), ReasoningLevel::Ultra);
5421    }
5422
5423    /// Providers without exact metadata must not gain Max/Ultra.
5424    #[test]
5425    fn checked_rejects_extended_levels_without_metadata() {
5426        let mut rt = Runtime::new_headless();
5427        rt.set_model("claude-opus-4-7".to_string());
5428        rt.set_reasoning_level(ReasoningLevel::Low);
5429        for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5430            assert!(rt.set_reasoning_level_checked(level).is_err());
5431            assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5432        }
5433    }
5434
5435    /// Gap fix: unknown Codex ids (no cache, no static metadata) must reject
5436    /// the extended Max/Ultra modes at mutation time, fail closed.
5437    #[test]
5438    fn checked_rejects_max_ultra_for_unknown_codex_model() {
5439        let mut rt = codex_runtime("gpt-unknown-future");
5440        rt.set_reasoning_level(ReasoningLevel::Low);
5441        for level in [ReasoningLevel::Max, ReasoningLevel::Ultra] {
5442            let err = rt.set_reasoning_level_checked(level).unwrap_err();
5443            assert!(err.contains("no capability metadata"), "{err}");
5444            assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5445        }
5446    }
5447
5448    /// xAI: Off is rejected (not silently omitted) on models whose reasoning
5449    /// cannot be disabled; unsupported named efforts are rejected exactly.
5450    #[test]
5451    fn checked_enforces_xai_capability_matrix_no_mutation_on_err() {
5452        let mut rt = Runtime::new_headless();
5453        rt.set_model("xai-auth/grok-4.5".to_string());
5454        // Model default (not explicit) applied on switch: documented high.
5455        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5456        for level in [
5457            ReasoningLevel::Off,
5458            ReasoningLevel::XHigh,
5459            ReasoningLevel::Ultra,
5460        ] {
5461            let before = rt.reasoning_level();
5462            assert!(rt.set_reasoning_level_checked(level).is_err(), "{level}");
5463            assert_eq!(rt.reasoning_level(), before);
5464        }
5465        rt.set_reasoning_level_checked(ReasoningLevel::Low)
5466            .expect("grok-4.5 supports low");
5467        assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5468    }
5469
5470    /// xAI models without documented effort control reject explicit named
5471    /// levels; model switch applies the Adaptive provider-default.
5472    #[test]
5473    fn checked_rejects_named_on_intrinsic_xai_model_and_defaults_adaptive() {
5474        let mut rt = Runtime::new_headless();
5475        rt.set_model("xai-auth/grok-4.3".to_string());
5476        assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5477        assert!(rt
5478            .set_reasoning_level_checked(ReasoningLevel::Medium)
5479            .is_err());
5480        assert_eq!(rt.reasoning_level(), ReasoningLevel::Adaptive);
5481        rt.set_reasoning_level_checked(ReasoningLevel::Adaptive)
5482            .unwrap();
5483    }
5484
5485    /// Explicit user choice survives an xAI model switch (no default overwrite).
5486    #[test]
5487    fn explicit_level_survives_xai_model_switch() {
5488        let mut rt = Runtime::new_headless();
5489        rt.set_model("xai-auth/grok-4.5".to_string());
5490        rt.set_reasoning_level_checked(ReasoningLevel::Low).unwrap();
5491        rt.set_model("xai-auth/grok-4.5-latest".to_string());
5492        assert_eq!(
5493            rt.reasoning_level(),
5494            ReasoningLevel::Low,
5495            "explicit level must not be overwritten by the model default"
5496        );
5497    }
5498
5499    /// Explicit level the new model rejects is clamped to the model default;
5500    /// provenance stays explicit so later switches still honor the user.
5501    #[test]
5502    fn explicit_unsupported_level_is_clamped_to_model_default_on_switch() {
5503        let mut rt = Runtime::new_headless();
5504        rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5505        let clamp = rt
5506            .try_set_model("xai-auth/grok-4.6".to_string())
5507            .expect("model switch succeeds");
5508        assert_eq!(
5509            clamp,
5510            Some(ReasoningClamp {
5511                from: ReasoningLevel::XHigh,
5512                to: ReasoningLevel::High,
5513            })
5514        );
5515        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5516        assert!(
5517            rt.is_reasoning_explicit(),
5518            "clamp keeps explicit provenance"
5519        );
5520        assert_eq!(rt.model(), "xai-auth/grok-4.6");
5521    }
5522
5523    /// Explicit level the new model supports is left alone: no clamp reported.
5524    #[test]
5525    fn explicit_supported_level_is_not_clamped_on_switch() {
5526        let mut rt = Runtime::new_headless();
5527        rt.set_reasoning_level_explicit(ReasoningLevel::High);
5528        let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5529        assert_eq!(clamp, None);
5530        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5531        assert!(rt.is_reasoning_explicit());
5532    }
5533
5534    /// Non-explicit levels keep taking the new model's default (unchanged
5535    /// behavior) and report no clamp — the default is applied, not substituted.
5536    #[test]
5537    fn non_explicit_default_application_reports_no_clamp() {
5538        let mut rt = Runtime::new_headless();
5539        rt.set_reasoning_level(ReasoningLevel::XHigh);
5540        let clamp = rt.try_set_model("xai-auth/grok-4.6".to_string()).unwrap();
5541        assert_eq!(clamp, None);
5542        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5543        assert!(!rt.is_reasoning_explicit());
5544    }
5545
5546    /// Explicit Max on an xAI model without Max support clamps to a level that
5547    /// validates for that exact model (documented default Adaptive).
5548    #[test]
5549    fn explicit_max_clamps_to_validating_option_on_multi_agent_model() {
5550        let model = "xai-auth/grok-4.20-multi-agent-0309";
5551        let mut rt = Runtime::new_headless();
5552        rt.set_reasoning_level_explicit(ReasoningLevel::Max);
5553        let clamp = rt
5554            .try_set_model(model.to_string())
5555            .unwrap()
5556            .expect("Max is not supported: must clamp");
5557        assert_eq!(clamp.from, ReasoningLevel::Max);
5558        assert!(
5559            crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
5560                model, clamp.to
5561            )
5562            .is_ok(),
5563            "clamped level {} must validate",
5564            clamp.to
5565        );
5566        assert_eq!(rt.reasoning_level(), clamp.to);
5567        assert!(rt.is_reasoning_explicit());
5568    }
5569
5570    /// `set_model` (infallible wrapper) still surfaces the clamp.
5571    #[test]
5572    fn set_model_returns_clamp() {
5573        let mut rt = Runtime::new_headless();
5574        rt.set_reasoning_level_explicit(ReasoningLevel::XHigh);
5575        let clamp = rt.set_model("xai-auth/grok-4.6".to_string());
5576        assert_eq!(clamp.map(|c| c.to), Some(ReasoningLevel::High));
5577    }
5578
5579    /// Resume path: a saved grok+xhigh session (written before clamping
5580    /// existed) must not resume into a permanently failing state.
5581    #[test]
5582    fn restore_session_reasoning_clamps_after_reapply() {
5583        let mut rt = Runtime::new_headless();
5584        rt.set_model("xai-auth/grok-4.6".to_string());
5585        let clamp = rt.restore_session_reasoning("xhigh");
5586        assert_eq!(
5587            clamp,
5588            Some(ReasoningClamp {
5589                from: ReasoningLevel::XHigh,
5590                to: ReasoningLevel::High,
5591            })
5592        );
5593        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5594        assert!(rt.is_reasoning_explicit());
5595        assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5596    }
5597
5598    /// Supported saved level restores verbatim with no clamp.
5599    #[test]
5600    fn restore_session_reasoning_keeps_supported_level() {
5601        let mut rt = Runtime::new_headless();
5602        rt.set_model("xai-auth/grok-4.6".to_string());
5603        assert_eq!(rt.restore_session_reasoning("low"), None);
5604        assert_eq!(rt.reasoning_level(), ReasoningLevel::Low);
5605        assert!(rt.is_reasoning_explicit());
5606    }
5607
5608    /// Boot path: config `model` + unsupported explicit `thinking` no longer
5609    /// boots into a permanently failing state.
5610    #[test]
5611    fn apply_config_clamps_unsupported_explicit_level_against_config_model() {
5612        let mut rt = Runtime::new_headless();
5613        rt.apply_config(&crate::config::SynapsConfig {
5614            model: Some("xai-auth/grok-4.6".to_string()),
5615            thinking_level: Some(ReasoningLevel::XHigh),
5616            ..Default::default()
5617        });
5618        assert_eq!(rt.model(), "xai-auth/grok-4.6");
5619        assert_eq!(rt.reasoning_level(), ReasoningLevel::High);
5620        assert!(rt.is_reasoning_explicit());
5621        assert!(rt.set_reasoning_level_checked(rt.reasoning_level()).is_ok());
5622    }
5623}
5624
5625#[cfg(test)]
5626mod codex_execution_preflight_tests {
5627    use super::*;
5628    use crate::runtime::openai::catalog::CodexRequestRole;
5629    use agent_core::prompt::QualifiedModelId;
5630    use agent_core::reasoning::ReasoningLevel;
5631
5632    fn ultra_runtime() -> Runtime {
5633        let mut runtime = Runtime::new_headless();
5634        runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
5635        runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5636        runtime
5637    }
5638
5639    fn install_baseline(runtime: &mut Runtime) {
5640        let foreground =
5641            QualifiedModelId::parse("openai-codex/gpt-5.6-sol").expect("qualified model");
5642        let orchestration = crate::orchestration::OrchestrationRuntime::baseline(foreground, 3, 8)
5643            .expect("baseline orchestration");
5644        runtime.install_orchestration(Arc::new(orchestration));
5645    }
5646
5647    #[tokio::test]
5648    async fn foreground_ultra_preflight_requires_orchestration() {
5649        let runtime = ultra_runtime();
5650        let error = runtime
5651            .validate_request_preflight()
5652            .await
5653            .expect_err("Ultra without orchestration must fail closed");
5654        assert!(error.to_string().contains("orchestration"), "{error}");
5655    }
5656
5657    #[tokio::test]
5658    async fn foreground_ultra_preflight_requires_actionable_subagent_tools() {
5659        let mut runtime = ultra_runtime();
5660        install_baseline(&mut runtime);
5661        runtime.set_tools(ToolRegistry::without_subagent());
5662
5663        let error = runtime
5664            .validate_request_preflight()
5665            .await
5666            .expect_err("Ultra without subagent tools must fail closed");
5667        assert!(error.to_string().contains("subagent"), "{error}");
5668    }
5669
5670    #[tokio::test]
5671    async fn foreground_ultra_preflight_accepts_exact_model_policy_and_tools() {
5672        let mut runtime = ultra_runtime();
5673        install_baseline(&mut runtime);
5674        runtime
5675            .validate_request_preflight()
5676            .await
5677            .expect("fully provisioned Ultra must pass preflight");
5678    }
5679
5680    #[tokio::test]
5681    async fn worker_ultra_preflight_never_requires_or_enables_orchestration() {
5682        let mut runtime = ultra_runtime();
5683        runtime.set_codex_request_role(CodexRequestRole::Worker);
5684        runtime.set_tools(ToolRegistry::without_subagent());
5685        runtime
5686            .validate_request_preflight()
5687            .await
5688            .expect("worker Ultra is underlying max without proactive orchestration");
5689        assert_eq!(runtime.codex_request_role(), CodexRequestRole::Worker);
5690    }
5691
5692    #[tokio::test]
5693    async fn non_codex_ultra_preflight_fails_before_provider_work() {
5694        let mut runtime = Runtime::new_headless();
5695        runtime.set_model("anthropic/claude-opus-4-7".to_string());
5696        runtime.set_reasoning_level_explicit(ReasoningLevel::Ultra);
5697        let error = runtime
5698            .validate_request_preflight()
5699            .await
5700            .expect_err("non-Codex Ultra must fail closed");
5701        assert!(
5702            error.to_string().contains("unsupported_reasoning_level"),
5703            "{error}"
5704        );
5705    }
5706
5707    #[tokio::test]
5708    async fn compaction_preflight_validates_target_before_credentials() {
5709        let mut runtime = ultra_runtime();
5710        runtime.set_compaction_model(Some("claude-sonnet-4-6".to_string()));
5711        {
5712            let mut auth = runtime.auth.write().await;
5713            auth.auth_token.clear();
5714            auth.auth_type = "none".to_string();
5715            auth.refresh_token = None;
5716            auth.token_expires = None;
5717        }
5718
5719        let error = runtime
5720            .compact_call(Vec::new())
5721            .await
5722            .expect_err("Codex Ultra cannot silently cross into Anthropic compaction");
5723        assert!(
5724            error.to_string().contains("authoritative exact-model"),
5725            "preflight must reject the target before auth access: {error}"
5726        );
5727    }
5728}
5729
5730#[cfg(test)]
5731mod http_client_timeout_tests {
5732    //! Regression tests for the runtime HTTP client's timeout semantics.
5733    //!
5734    //! The client used to set `.timeout(300s)` — a **total wall-clock
5735    //! deadline** that keeps ticking even while a streaming response is
5736    //! actively delivering bytes (reqwest `TotalTimeoutBody` never resets).
5737    //! Two failure modes: a dead connection took a full 5 minutes to
5738    //! surface (incident: session 20260714-025948-3dab), and any healthy
5739    //! stream longer than 300s was killed mid-flight. The fix is an
5740    //! idle-based `read_timeout` that resets on every received chunk.
5741    //!
5742    //! Servers here are raw TCP writing chunked HTTP/1.1 — full control
5743    //! over inter-chunk delays with no framework buffering in the way.
5744
5745    use futures::StreamExt;
5746    use std::time::Duration;
5747    use tokio::io::AsyncWriteExt;
5748
5749    const CHUNKED_HEAD: &[u8] =
5750        b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n";
5751
5752    fn chunk(data: &str) -> Vec<u8> {
5753        format!("{:x}\r\n{}\r\n", data.len(), data).into_bytes()
5754    }
5755
5756    /// Spawn a raw server: writes headers, then `n_chunks` chunks spaced
5757    /// `gap` apart, then (optionally) the terminating chunk.
5758    async fn spawn_chunked_server(n_chunks: usize, gap: Duration, terminate: bool) -> String {
5759        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5760        let addr = listener.local_addr().unwrap();
5761        tokio::spawn(async move {
5762            let (mut sock, _) = listener.accept().await.unwrap();
5763            // Drain the request head (we don't care about its contents).
5764            let mut buf = [0u8; 4096];
5765            use tokio::io::AsyncReadExt;
5766            let _ = sock.read(&mut buf).await;
5767            sock.write_all(CHUNKED_HEAD).await.unwrap();
5768            for i in 0..n_chunks {
5769                sock.write_all(&chunk(&format!("data {i}\n")))
5770                    .await
5771                    .unwrap();
5772                sock.flush().await.unwrap();
5773                tokio::time::sleep(gap).await;
5774            }
5775            if terminate {
5776                sock.write_all(b"0\r\n\r\n").await.unwrap();
5777            } else {
5778                // Stall forever (until the client gives up and drops us).
5779                tokio::time::sleep(Duration::from_secs(60)).await;
5780            }
5781        });
5782        format!("http://{addr}/stream")
5783    }
5784
5785    async fn drain(
5786        client: &reqwest::Client,
5787        url: &str,
5788    ) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
5789        let resp = client.get(url).send().await?;
5790        let mut stream = resp.bytes_stream();
5791        let mut total = 0usize;
5792        while let Some(c) = stream.next().await {
5793            total += c?.len();
5794        }
5795        Ok(total)
5796    }
5797
5798    /// A healthy stream that keeps delivering bytes must NOT be killed,
5799    /// even when its total duration exceeds the read timeout — received
5800    /// data resets the idle clock. (This is the property the old total
5801    /// deadline violated at 300s.)
5802    #[tokio::test]
5803    async fn active_stream_outliving_read_timeout_survives() {
5804        // 15 chunks × 100ms ≈ 1.5s total, read_timeout = 400ms.
5805        let url = spawn_chunked_server(15, Duration::from_millis(100), true).await;
5806        let client = super::build_http_client(Duration::from_millis(400)).expect("client builds");
5807        let total = drain(&client, &url)
5808            .await
5809            .expect("active stream must never be killed by the idle timeout");
5810        assert!(total > 0);
5811    }
5812
5813    /// A stream that stalls (bytes stop arriving) must be killed within
5814    /// the read timeout — not after a 300s total deadline.
5815    #[tokio::test]
5816    async fn stalled_stream_is_killed_by_read_timeout() {
5817        // 2 quick chunks then permanent stall, read_timeout = 300ms.
5818        let url = spawn_chunked_server(2, Duration::from_millis(10), false).await;
5819        let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5820        let start = std::time::Instant::now();
5821        let err = drain(&client, &url)
5822            .await
5823            .expect_err("stalled stream must be detected");
5824        assert!(
5825            start.elapsed() < Duration::from_secs(5),
5826            "stall must be detected promptly, took {:?}",
5827            start.elapsed()
5828        );
5829        let msg = crate::core::error::error_chain_string(err.as_ref());
5830        assert!(
5831            msg.contains("timed out") || msg.contains("timeout"),
5832            "must be a timeout error: {msg}"
5833        );
5834    }
5835
5836    /// A server that accepts but never sends response headers must be
5837    /// killed by the read timeout too (the incident's failure mode —
5838    /// previously took the full 300s total deadline to surface).
5839    #[tokio::test]
5840    async fn hung_headers_are_killed_by_read_timeout() {
5841        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5842        let addr = listener.local_addr().unwrap();
5843        tokio::spawn(async move {
5844            let (_sock, _) = listener.accept().await.unwrap();
5845            tokio::time::sleep(Duration::from_secs(60)).await;
5846        });
5847        let client = super::build_http_client(Duration::from_millis(300)).expect("client builds");
5848        let start = std::time::Instant::now();
5849        let err = client
5850            .get(format!("http://{addr}/codex/responses"))
5851            .send()
5852            .await
5853            .expect_err("hung request must be detected");
5854        assert!(
5855            start.elapsed() < Duration::from_secs(5),
5856            "hung headers must be detected promptly, took {:?}",
5857            start.elapsed()
5858        );
5859        assert!(err.is_timeout(), "must be a timeout: {err:?}");
5860    }
5861}
5862
5863/// The memory binding of a runtime: made once, for the runtime's folder,
5864/// whatever an old config still says about a backend.
5865#[cfg(test)]
5866mod memory_binding_tests {
5867    use super::Runtime;
5868    use crate::test_env::{BaseDirGuard, EnvVarGuard};
5869    use crate::tools::test_helpers::context_with_memory;
5870    use crate::tools::Tool;
5871    use serde_json::json;
5872    use std::path::Path;
5873
5874    /// A folder that is a project of its own.
5875    fn project() -> tempfile::TempDir {
5876        let dir = tempfile::tempdir().unwrap();
5877        std::fs::write(dir.path().join(".synaps-project"), "").unwrap();
5878        dir
5879    }
5880
5881    fn key_of(dir: &Path) -> String {
5882        let scope = agent_core::memory::store::ProjectScope::discover(dir).unwrap();
5883        scope.key().to_owned()
5884    }
5885
5886    fn bound_key(runtime: &Runtime) -> String {
5887        runtime.memory_backend.scope().unwrap().key().to_owned()
5888    }
5889
5890    /// A config from before the cut, with all four backend keys active. The
5891    /// helper and the brain it names do not exist, and the brain path is
5892    /// relative: each of these used to leave the backend unavailable.
5893    const OLD_AXEL_CONFIG: &str = "memory.backend = axel\n\
5894         memory.user_scope = true\n\
5895         memory.axel.executable = /nonexistent/synaps-axel-memory-service\n\
5896         memory.axel.brain = relative/brain.r8\n\
5897         context_management.mode = auto\n";
5898
5899    /// The binding is made by the first `apply_config`, for the folder the
5900    /// runtime was given before it, and never again: not by a second
5901    /// `apply_config`, not after the folder changes, not when the config
5902    /// changes. A clone keeps it, with the same author.
5903    #[test]
5904    #[serial_test::serial(synaps_base_dir)]
5905    fn a_runtime_binds_its_folders_project_once() {
5906        let _root = EnvVarGuard::remove("SYNAPS_PROJECT_ROOT");
5907        let (first, second) = (project(), project());
5908        assert_ne!(key_of(first.path()), key_of(second.path()));
5909        let plain = agent_core::config::load_config_from_str("");
5910        let old = agent_core::config::load_config_from_str(OLD_AXEL_CONFIG);
5911
5912        let mut runtime = Runtime::new_headless();
5913        runtime.set_cwd(Some(first.path().to_path_buf()));
5914        runtime.apply_config(&plain);
5915        assert_eq!(bound_key(&runtime), key_of(first.path()));
5916        let author = runtime.memory_backend.author().clone();
5917
5918        runtime.set_cwd(Some(second.path().to_path_buf()));
5919        runtime.apply_config(&plain);
5920        runtime.apply_config(&old);
5921        runtime.bind_memory();
5922        assert_eq!(bound_key(&runtime), key_of(first.path()));
5923        assert_eq!(runtime.memory_backend.author(), &author);
5924
5925        let clone = runtime.clone();
5926        assert_eq!(bound_key(&clone), key_of(first.path()));
5927        assert_eq!(clone.memory_backend.author(), &author);
5928        assert_eq!(clone.memory_backend.base(), runtime.memory_backend.base());
5929    }
5930
5931    /// T-B2, ruling 4: a config that still has `memory.backend = axel` and
5932    /// the three keys beside it starts a runtime. It reports the one
5933    /// warning, passes the request preflight, lets automatic
5934    /// context management be switched on, and its memory tools and its
5935    /// rollover seal act on the plain files of the chat's project. No
5936    /// helper is started and no brain is made.
5937    #[tokio::test]
5938    #[serial_test::serial(synaps_base_dir)]
5939    async fn an_old_axel_config_starts_a_runtime_on_plain_files() {
5940        let base = BaseDirGuard::new();
5941        let _root = EnvVarGuard::remove("SYNAPS_PROJECT_ROOT");
5942        let chat = project();
5943        let config = agent_core::config::load_config_from_str(OLD_AXEL_CONFIG);
5944        assert_eq!(config.warnings.len(), 1, "{:?}", config.warnings);
5945        assert!(config.warnings[0].starts_with("The Axel memory backend (memory.backend = axel, "));
5946        assert_eq!(
5947            config.context_management.mode,
5948            agent_core::config::ContextManagementMode::Auto
5949        );
5950
5951        let mut runtime = Runtime::new_headless();
5952        runtime.set_cwd(Some(chat.path().to_path_buf()));
5953        runtime.apply_config(&config);
5954        runtime.apply_config(&config);
5955        // No "selected memory backend unavailable" or reconfigure-denied
5956        // refusal is left in the preflight every request passes.
5957        runtime
5958            .validate_request_preflight()
5959            .await
5960            .expect("an old Axel config passes the request preflight");
5961        let key = key_of(chat.path());
5962        assert_eq!(bound_key(&runtime), key);
5963        assert_eq!(runtime.memory_backend.base(), base.path());
5964        // Automatic rollover is on from the config and can be set again:
5965        // the "selected backend unavailable" refusals are gone.
5966        assert!(runtime.context_management_enabled());
5967        runtime.context_management_command("auto").expect("/context auto is not refused");
5968
5969        let ctx = || context_with_memory(runtime.memory_backend.clone());
5970        let stored = crate::tools::memory::MemoryStoreTool
5971            .execute(json!({"content": "NOTE_UNDER_AN_OLD_CONFIG"}), ctx())
5972            .await
5973            .unwrap();
5974        assert!(stored.contains(&format!("project {key}")), "{stored}");
5975        let id = stored.split_whitespace().nth(2).unwrap().to_owned();
5976        // The note is a line of the project's plain file.
5977        let file = base.path().join("memory").join(format!("project-{key}.jsonl"));
5978        let lines = std::fs::read_to_string(&file).expect("the project's notes file");
5979        assert!(lines.contains("NOTE_UNDER_AN_OLD_CONFIG") && lines.contains(&id), "{lines}");
5980        let found = crate::tools::memory::MemorySearchTool
5981            .execute(json!({"query": "NOTE_UNDER_AN_OLD_CONFIG"}), ctx())
5982            .await
5983            .unwrap();
5984        assert!(found.contains(&id), "{found}");
5985        let fetched = crate::tools::memory::MemoryFetchTool
5986            .execute(json!({"ids": [id]}), ctx())
5987            .await
5988            .unwrap();
5989        assert!(fetched.contains("NOTE_UNDER_AN_OLD_CONFIG"), "{fetched}");
5990
5991        // A rollover's seal writes an archive file under the same project.
5992        let window = vec![std::sync::Arc::new(json!({"role": "user", "content": "sealed words"}))];
5993        let (parts, _) = runtime
5994            .memory_backend
5995            .history_seal_window("old-config-chat", &window, "")
5996            .await
5997            .unwrap();
5998        let archive = &parts[0].archive.id;
5999        let sealed = agent_core::context_archive::archive_file(base.path(), &key, archive);
6000        assert!(sealed.unwrap().expect("an archive file").starts_with(base.path()));
6001        let history = crate::tools::memory::MemoryFetchTool
6002            .execute(json!({"ids": [format!("ctx-{archive}")]}), ctx())
6003            .await
6004            .unwrap();
6005        assert!(history.contains("sealed words"), "{history}");
6006
6007        crate::tools::memory::MemoryForgetTool
6008            .execute(json!({"id": id}), ctx())
6009            .await
6010            .unwrap();
6011        // Nothing of the old backend was made: the two plain stores are
6012        // there, a brain is not.
6013        assert!(base.path().join("memory").is_dir());
6014        assert!(base.path().join("context-archives").is_dir());
6015        assert!(!base.path().join("memory/axel").exists());
6016        assert!(!chat.path().join("relative").exists());
6017    }
6018
6019    /// A binding made with no folder, or with a deleted one, has no project:
6020    /// no id is invented for it. (The lease half of this test went with
6021    /// automatic memory.)
6022    #[test]
6023    #[serial_test::serial(synaps_base_dir)]
6024    fn a_binding_without_a_folder_has_no_project() {
6025        let gone = tempfile::tempdir().unwrap();
6026        let gone_path = gone.path().join("deleted");
6027        std::fs::create_dir(&gone_path).unwrap();
6028        std::fs::remove_dir(&gone_path).unwrap();
6029        for cwd in [None, Some(gone_path)] {
6030            let mut runtime = Runtime::new_headless();
6031            runtime.memory_backend = crate::memory_backend::MemoryBinding::for_cwd(cwd.clone());
6032            assert!(runtime.memory_backend.scope().is_err(), "{cwd:?} must have no project");
6033        }
6034    }
6035
6036    /// An old config with the removed automatic-memory keys reaches nothing
6037    /// in a runtime: it is bound exactly like one given an empty config.
6038    #[test]
6039    #[serial_test::serial(synaps_base_dir)]
6040    fn an_old_automatic_memory_config_changes_nothing_on_a_runtime() {
6041        let old = agent_core::config::load_config_from_str(
6042            "memory.default_mode = capture_and_recall\n\
6043             memory.default_mode_confirmed = true\n\
6044             memory.recall_max_records = 12\n\
6045             memory.recall_max_tokens = 2048\n\
6046             memory.recall_timeout_ms = 250\n\
6047             memory.capture_tools = off\n\
6048             memory.capture_assistant = false\n\
6049             memory.capture_user = false\n\
6050             memory.auto_consolidate = on\n\
6051             memory.local_embeddings = on\n\
6052             memory.gliner = on\n",
6053        );
6054        assert_eq!(old.warnings.len(), 1, "{:?}", old.warnings);
6055        let plain = agent_core::config::load_config_from_str("");
6056        let mut with_old = Runtime::new_headless();
6057        with_old.apply_config(&old);
6058        with_old.apply_config(&old);
6059        let mut with_plain = Runtime::new_headless();
6060        with_plain.apply_config(&plain);
6061        assert_eq!(with_old.memory_backend.base(), with_plain.memory_backend.base());
6062        assert_eq!(
6063            with_old.memory_backend.scope().ok().map(|scope| scope.key().to_owned()),
6064            with_plain.memory_backend.scope().ok().map(|scope| scope.key().to_owned()),
6065        );
6066    }
6067
6068    /// Any `memory.backend` value binds the same plain-file project as no
6069    /// such line: there is no unavailable backend to fall into.
6070    #[test]
6071    #[serial_test::serial(synaps_base_dir)]
6072    fn any_backend_selector_binds_the_same_plain_files() {
6073        let _root = EnvVarGuard::remove("SYNAPS_PROJECT_ROOT");
6074        let chat = project();
6075        for selector in ["axel", "legacy", "sqlite", "invalid-not-axel-not-legacy", ""] {
6076            let mut runtime = Runtime::new_headless();
6077            runtime.set_cwd(Some(chat.path().to_path_buf()));
6078            runtime.apply_config(&agent_core::config::load_config_from_str(&format!(
6079                "memory.backend = {selector}\n"
6080            )));
6081            assert_eq!(bound_key(&runtime), key_of(chat.path()), "{selector}");
6082            assert_eq!(runtime.memory_backend.base(), agent_core::config::base_dir());
6083        }
6084    }
6085}